its been a while

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

No files matched your search

+8
View File
@@ -4,6 +4,14 @@ KEYWARDEN_DEBUG=True
KEYWARDEN_ALLOWED_HOSTS=* KEYWARDEN_ALLOWED_HOSTS=*
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1 KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
KEYWARDEN_DOMAIN=https://example.domain.xyz 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 # Database
KEYWARDEN_POSTGRES_DB=keywarden KEYWARDEN_POSTGRES_DB=keywarden
+35
View File
@@ -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
View File
@@ -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> ./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. You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environment variables.
## Config ## Config
On first boot, the agent will create a config file if it does not exist. Only `server_url` is required for bootstrapping. 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. 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`. See `config.example.json`.
For package alternatives and external collector options, see `LOGGING_PACKAGES.md`.
+273 -6
View File
@@ -4,15 +4,20 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/hex"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"sort"
"strings"
"syscall" "syscall"
"time" "time"
@@ -23,10 +28,13 @@ import (
"keywarden/agent/internal/version" "keywarden/agent/internal/version"
) )
var lastNoEventsNotice time.Time
func main() { func main() {
configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON") configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON")
serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)") serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)")
enrollToken := flag.String("enroll-token", "", "Enrollment token (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") showVersion := flag.Bool("version", false, "Print version and exit")
flag.Parse() flag.Parse()
@@ -43,7 +51,7 @@ func main() {
log.Fatalf("state dir error: %v", err) 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) 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) log.Printf("host update deferred; will retry: %v", err)
} else { } else {
log.Printf("host update error: %v", err) log.Printf("host update error: %v", err)
logAgentUnauthorizedHint(err)
} }
} }
if err := apiClient.SyncAccounts(ctx, cfg); err != nil { if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
log.Printf("sync accounts error: %v", err) log.Printf("sync accounts error: %v", err)
logAgentUnauthorizedHint(err)
} }
if err := shipLogs(ctx, apiClient, cfg); err != nil { if err := shipLogs(ctx, apiClient, cfg); err != nil {
if client.IsRetriable(err) { if client.IsRetriable(err) {
log.Printf("log shipping deferred; will retry: %v", err) log.Printf("log shipping deferred; will retry: %v", err)
} else { } else {
log.Printf("log shipping error: %v", err) 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 { func ensureDirs(cfg *config.Config) error {
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil { if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
return err return err
@@ -114,16 +132,56 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
return err 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 { if err != nil {
return err return err
} }
collector := logs.NewCollector() 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 { if err != nil {
return err 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 len(events) == 0 {
if maybeLogNoEventsNotice(len(sources)) && len(sources) == 0 {
log.Printf("log shipping file fallback probe: %s", describeFileSourceAvailability(defaultFileFallbackSources()))
}
return nil return nil
} }
payload, err := json.Marshal(events) payload, err := json.Marshal(events)
@@ -136,7 +194,7 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
} }
return err return err
} }
if err := logs.WriteCursor(cfg.LogCursorPath(), nextCursor); err != nil { if err := logs.WriteState(cfg.LogStatePath(), nextState); err != nil {
return err return err
} }
return nil return nil
@@ -172,13 +230,22 @@ func pickEnrollToken(flagValue string) string {
return os.Getenv("KEYWARDEN_ENROLL_TOKEN") return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
} }
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string) error { func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string, forceEnroll bool) error {
if cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) { 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 return nil
} }
if enrollToken == "" { if enrollToken == "" {
return fmt.Errorf("missing enrollment token; set KEYWARDEN_ENROLL_TOKEN or -enroll-token") 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() keyPath := cfg.ClientKeyPath()
if !fileExists(keyPath) { if !fileExists(keyPath) {
if err := generateKey(keyPath); err != nil { if err := generateKey(keyPath); err != nil {
@@ -208,12 +275,156 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
return err return err
} }
cfg.ServerID = resp.ServerID 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 { if err := config.Save(configPath, cfg); err != nil {
return err return err
} }
return nil 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 { func retry(ctx context.Context, delays []time.Duration, fn func() error) error {
var lastErr error var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ { for attempt := 0; attempt <= len(delays); attempt++ {
@@ -276,3 +487,59 @@ func fileExists(path string) bool {
} }
return !info.IsDir() 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, ";")
}
+54 -4
View File
@@ -1,9 +1,59 @@
{ {
"server_url": "https://keywarden.dev.ntbx.io/api/v1", "server_url": "https://keywarden.example.com/api/v1",
"server_id": "4", "server_id": "6",
"server_ca_path": "", "agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
"sync_interval_seconds": 5, "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", "state_dir": "/var/lib/keywarden-agent",
"account_policy": { "account_policy": {
"username_template": "{{username}}_{{user_id}}", "username_template": "{{username}}_{{user_id}}",
+15
View File
@@ -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
}
}
+69
View File
@@ -28,6 +28,18 @@ type Client struct {
scheme string scheme string
host string host string
addr 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) { func New(cfg *config.Config) (*Client, error) {
@@ -35,6 +47,10 @@ func New(cfg *config.Config) (*Client, error) {
if baseURL == "" { if baseURL == "" {
return nil, errors.New("server url is required") 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()) cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
if err != nil { if err != nil {
return nil, fmt.Errorf("load client cert: %w", err) return nil, fmt.Errorf("load client cert: %w", err)
@@ -97,9 +113,17 @@ func New(cfg *config.Config) (*Client, error) {
scheme: scheme, scheme: scheme,
host: host, host: host,
addr: addr, addr: addr,
token: agentToken,
}, nil }, nil
} }
func (c *Client) setAuth(req *http.Request) {
if c.token == "" {
return
}
req.Header.Set("Authorization", "Bearer "+c.token)
}
type EnrollRequest struct { type EnrollRequest struct {
Token string `json:"token"` Token string `json:"token"`
CSRPEM string `json:"csr_pem"` CSRPEM string `json:"csr_pem"`
@@ -113,6 +137,7 @@ type EnrollResponse struct {
ServerID string `json:"server_id"` ServerID string `json:"server_id"`
ClientCert string `json:"client_cert_pem"` ClientCert string `json:"client_cert_pem"`
CACert string `json:"ca_cert_pem"` CACert string `json:"ca_cert_pem"`
AgentToken string `json:"agent_api_token,omitempty"`
SyncProfile string `json:"sync_profile,omitempty"` SyncProfile string `json:"sync_profile,omitempty"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
} }
@@ -149,6 +174,19 @@ type SyncReportRequest struct {
Accounts []AccountSyncEntry `json:"accounts,omitempty"` 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) { func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
baseURL := strings.TrimRight(serverURL, "/") baseURL := strings.TrimRight(serverURL, "/")
if baseURL == "" { if baseURL == "" {
@@ -241,6 +279,7 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc
if err != nil { if err != nil {
return nil, fmt.Errorf("build account access request: %w", err) return nil, fmt.Errorf("build account access request: %w", err)
} }
c.setAuth(req)
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch account access: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("build user ca request: %w", err) return nil, fmt.Errorf("build user ca request: %w", err)
} }
c.setAuth(req)
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch user ca: %w", err) 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) return fmt.Errorf("build sync report: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("send sync report: %w", err) 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 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 { 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)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
if err != nil { if err != nil {
return fmt.Errorf("build log request: %w", err) return fmt.Errorf("build log request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("send log batch: %w", err) 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) return fmt.Errorf("build host update: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("send host update: %w", err) return fmt.Errorf("send host update: %w", err)
+97 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
) )
@@ -26,12 +27,28 @@ type AccountPolicy struct {
LockOnRevoke bool `json:"lock_on_revoke"` 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 { type Config struct {
ServerURL string `json:"server_url"` ServerURL string `json:"server_url"`
ServerID string `json:"server_id,omitempty"` ServerID string `json:"server_id,omitempty"`
AgentAPIToken string `json:"agent_api_token,omitempty"`
ServerCAPath string `json:"server_ca_path,omitempty"` ServerCAPath string `json:"server_ca_path,omitempty"`
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"` SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
LogBatchSize int `json:"log_batch_size,omitempty"` LogBatchSize int `json:"log_batch_size,omitempty"`
LogSources []LogSource `json:"log_sources,omitempty"`
StateDir string `json:"state_dir,omitempty"` StateDir string `json:"state_dir,omitempty"`
AccountPolicy AccountPolicy `json:"account_policy,omitempty"` AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
} }
@@ -48,7 +65,11 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
if serverURL == "" { if serverURL == "" {
return nil, errors.New("server url required for first boot") 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) applyDefaults(cfg)
if err := validate(cfg, false); err != nil { if err := validate(cfg, false); err != nil {
return nil, err return nil, err
@@ -62,9 +83,15 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
if err := json.Unmarshal(data, cfg); err != nil { if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err) return nil, fmt.Errorf("parse config: %w", err)
} }
envAgentToken := strings.TrimSpace(os.Getenv("KEYWARDEN_AGENT_API_TOKEN"))
if cfg.ServerCAPath == "" { if cfg.ServerCAPath == "" {
cfg.ServerCAPath = os.Getenv("KEYWARDEN_SERVER_CA_PATH") 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) applyDefaults(cfg)
if err := validate(cfg, false); err != nil { if err := validate(cfg, false); err != nil {
return nil, err return nil, err
@@ -108,6 +135,8 @@ func applyDefaults(cfg *Config) {
} }
func validate(cfg *Config, requireServerID bool) error { func validate(cfg *Config, requireServerID bool) error {
cfg.ServerID = strings.TrimSpace(cfg.ServerID)
cfg.AgentAPIToken = strings.TrimSpace(cfg.AgentAPIToken)
var missing []string var missing []string
if cfg.ServerURL == "" { if cfg.ServerURL == "" {
missing = append(missing, "server_url") missing = append(missing, "server_url")
@@ -118,12 +147,57 @@ func validate(cfg *Config, requireServerID bool) error {
if len(missing) > 0 { if len(missing) > 0 {
return fmt.Errorf("missing required config fields: %v", missing) 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 { if cfg.SyncIntervalSeconds < 5 {
return errors.New("sync_interval_seconds must be >= 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 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 { func (c *Config) ClientCertPath() string {
return c.StateDir + "/agent.crt" return c.StateDir + "/agent.crt"
} }
@@ -144,6 +218,28 @@ func (c *Config) LogSpoolDir() string {
return c.StateDir + "/spool" 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 { func dir(path string) string {
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 { if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
return path[:idx] return path[:idx]
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")
}
}
+35
View File
@@ -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)
}
+35
View File
@@ -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)
}
+21
View File
@@ -6,6 +6,8 @@ type Event struct {
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
Category string `json:"category"` Category string `json:"category"`
EventType string `json:"event_type"` EventType string `json:"event_type"`
SourceKind string `json:"source_kind,omitempty"`
SourceName string `json:"source_name,omitempty"`
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Priority string `json:"priority,omitempty"` Priority string `json:"priority,omitempty"`
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
@@ -21,3 +23,22 @@ type Event struct {
func NewEvent(ts time.Time) Event { func NewEvent(ts time.Time) Event {
return Event{Timestamp: ts.UTC().Format(time.RFC3339Nano)} 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.
+1
View File
@@ -17,6 +17,7 @@ def sync_server_view_perm(access_request: AccessRequest) -> None:
server_id=access_request.server_id, server_id=access_request.server_id,
status=AccessRequest.Status.APPROVED, 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)) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.exists() .exists()
) )
@@ -205,6 +205,32 @@
</div> </div>
<aside class="space-y-6 lg:sticky lg:top-6"> <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"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2"> <div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2> <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]"); var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) { for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload); downloadButtons[i].addEventListener("click", handleDownload);
@@ -393,6 +444,32 @@
for (var j = 0; j < buttons.length; j += 1) { for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate); 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> </script>
{% endblock %} {% endblock %}
+81 -1
View File
@@ -9,7 +9,25 @@ except ImportError: # Fallback for older Unfold builds without guardian admin s
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin): class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass 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) @admin.register(Server)
@@ -29,6 +47,7 @@ class ServerAdmin(GuardedModelAdmin):
"created_at", "created_at",
"updated_at", "updated_at",
) )
inlines = (ServerLogSourceInline,)
def avatar(self, obj: Server): def avatar(self, obj: Server):
if obj.image_url: if obj.image_url:
@@ -92,3 +111,64 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
for ca in queryset: for ca in queryset:
ca.revoke() ca.revoke()
ca.save(update_fields=["is_active", "revoked_at"]) 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
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import secrets import secrets
import shlex
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -34,8 +35,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.tempdir = None self.tempdir = None
self.tempdir_path = "" self.tempdir_path = ""
self.ssh_started = False self.ssh_started = False
self.key_cleanup_task = None self.ssh_output_seen = False
self.key_paths = [] self.key_path = ""
self.cert_path = ""
self.system_username = "" self.system_username = ""
self.shell_target = "" self.shell_target = ""
self.server_id: int | None = None self.server_id: int | None = None
@@ -91,9 +93,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
if self.reader_task: if self.reader_task:
self.reader_task.cancel() self.reader_task.cancel()
self.reader_task = None 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: if self.proc and self.proc.returncode is None:
self.proc.terminate() self.proc.terminate()
try: try:
@@ -101,6 +100,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.proc.kill() self.proc.kill()
if self.tempdir_path and self.ssh_started: 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) shutil.rmtree(self.tempdir_path, ignore_errors=True)
self.tempdir_path = "" self.tempdir_path = ""
self.tempdir = None self.tempdir = None
@@ -131,38 +131,16 @@ class ShellConsumer(AsyncWebsocketConsumer):
user, user,
self.system_username, self.system_username,
) )
self.key_path = key_path
self.cert_path = cert_path
ssh_host = _format_ssh_host(self.shell_target) ssh_host = _format_ssh_host(self.shell_target)
# Use a locked-down, non-interactive SSH invocation suitable for websockets. # Build a locked-down SSH invocation and explicitly launch a remote shell.
command = [ command = _build_ssh_command(
"ssh", key_path=key_path,
"-tt", cert_path=cert_path,
"-i", username=self.system_username,
key_path, host=ssh_host,
"-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",
]
self.proc = await asyncio.create_subprocess_exec( self.proc = await asyncio.create_subprocess_exec(
*command, *command,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
@@ -170,8 +148,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
) )
self.ssh_started = True 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()) self.reader_task = asyncio.create_task(self._stream_output())
async def _stream_output(self): async def _stream_output(self):
@@ -182,21 +158,104 @@ class ShellConsumer(AsyncWebsocketConsumer):
chunk = await self.proc.stdout.read(4096) chunk = await self.proc.stdout.read(4096)
if not chunk: if not chunk:
break break
self.ssh_output_seen = True
await self.send(bytes_data=chunk) 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() 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: try:
await asyncio.sleep(delay_seconds) proc = await asyncio.create_subprocess_exec(
for path in self.key_paths: "ssh-keygen",
try: "-L",
os.remove(path) "-f",
except FileNotFoundError: self.cert_path,
continue stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except Exception: except Exception:
pass return ""
finally: output, _ = await proc.communicate()
self.key_paths = [] 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 @database_sync_to_async
def _get_server(self, user, server_id: int): 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("]")): if ":" in host and not (host.startswith("[") and host.endswith("]")):
return f"[{host}]" return f"[{host}]"
return host return host
def _build_ssh_command(
*,
key_path: str,
cert_path: str,
username: str,
host: str,
remote_command: str | None = None,
force_tty: bool = True,
log_level: str = "ERROR",
connect_timeout: int | None = None,
) -> list[str]:
command = [
"ssh",
]
if force_tty:
command.append("-tt")
command.extend(
[
"-i",
key_path,
"-o",
f"CertificateFile={cert_path}",
"-o",
"BatchMode=yes",
"-o",
"PasswordAuthentication=no",
"-o",
"KbdInteractiveAuthentication=no",
"-o",
"ChallengeResponseAuthentication=no",
"-o",
"PreferredAuthentications=publickey",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"VerifyHostKeyDNS=no",
"-o",
f"LogLevel={log_level}",
]
)
if connect_timeout is not None:
command.extend(["-o", f"ConnectTimeout={connect_timeout}"])
command.append(f"{username}@{host}")
if remote_command is None:
remote_command = str(getattr(settings, "KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")).strip()
else:
remote_command = str(remote_command).strip()
if not remote_command:
return command
try:
remote_argv = shlex.split(remote_command)
except ValueError:
remote_argv = [remote_command]
if remote_argv:
command.extend(remote_argv)
return command
def _is_truthy(value) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _truncate_lines(text: str, *, max_lines: int, max_chars: int) -> str:
if not text:
return ""
clipped = text[:max_chars]
lines = clipped.splitlines()
if len(lines) > max_lines:
head_count = max(1, max_lines // 2)
tail_count = max(1, max_lines - head_count)
lines = (
lines[:head_count]
+ ["... (middle omitted) ..."]
+ lines[-tail_count:]
)
if len(text) > max_chars:
lines.append("... (truncated by size)")
return "\n".join(lines)
def _summarize_probe_output(text: str) -> str:
if not text:
return ""
lines = text.splitlines()
lower_text = text.lower()
causes = []
if "account has expired" in lower_text:
causes.append("Remote Linux account is expired.")
if "permission denied (publickey" in lower_text or "permission denied" in lower_text:
causes.append("SSH authentication was denied by server policy.")
if "no such user" in lower_text:
causes.append("Remote user account does not exist.")
if "certificate invalid" in lower_text or "invalid certificate" in lower_text:
causes.append("SSH certificate was rejected by the server.")
keywords = (
"authenticating to",
"offering public key",
"offering publickey",
"server accepts key",
"sign_and_send_pubkey",
"authentications that can continue",
"permission denied",
"authentication succeeded",
"received disconnect",
"userauth",
"certificate",
"principal",
"no such user",
"account is locked",
)
interesting = []
for line in lines:
lower = line.lower()
if any(keyword in lower for keyword in keywords):
interesting.append(line)
out = []
if causes:
out.append("Detected cause:")
for cause in causes:
out.append(f"- {cause}")
out.append("")
if interesting:
out.append("Auth highlights:")
out.extend(interesting[-30:])
if lines:
out.append("")
out.append("Probe tail:")
out.extend(lines[-20:])
return _truncate_lines("\n".join(out), max_lines=80, max_chars=8000)
@@ -0,0 +1,49 @@
from django.db import migrations, models
import django.db.models.deletion
from django.utils import timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0009_server_heartbeat_fields"),
]
operations = [
migrations.CreateModel(
name="ServerAuditLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("event_at", models.DateTimeField(db_index=True)),
("received_at", models.DateTimeField(db_index=True, default=timezone.now, editable=False)),
("category", models.CharField(db_index=True, max_length=64)),
("event_type", models.CharField(db_index=True, max_length=128)),
("unit", models.CharField(blank=True, max_length=128)),
("priority", models.CharField(blank=True, db_index=True, max_length=16)),
("hostname", models.CharField(blank=True, max_length=253)),
("username", models.CharField(blank=True, db_index=True, max_length=150)),
("principal", models.CharField(blank=True, max_length=255)),
("source_ip", models.GenericIPAddressField(blank=True, null=True)),
("session_id", models.CharField(blank=True, max_length=128)),
("message", models.TextField(blank=True)),
("raw", models.TextField(blank=True)),
("fields", models.JSONField(blank=True, default=dict)),
(
"server",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="audit_logs", to="servers.server"),
),
],
options={
"verbose_name": "Server audit log",
"verbose_name_plural": "Server audit logs",
"ordering": ["-event_at", "-id"],
"indexes": [
models.Index(fields=["server", "event_at"], name="servers_audit_server_event_idx"),
models.Index(fields=["server", "category", "event_at"], name="servers_audit_cat_event_idx"),
models.Index(fields=["server", "event_type", "event_at"], name="servers_audit_type_event_idx"),
models.Index(fields=["server", "username", "event_at"], name="servers_audit_user_event_idx"),
models.Index(fields=["server", "source_ip", "event_at"], name="servers_audit_ip_event_idx"),
],
},
),
]
@@ -0,0 +1,42 @@
from django.db import migrations, models
import django.db.models.deletion
from django.utils import timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0010_serverauditlog"),
]
operations = [
migrations.CreateModel(
name="ServerLogSource",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("kind", models.CharField(choices=[("service", "Service"), ("file", "File")], db_index=True, max_length=16)),
("name", models.CharField(blank=True, max_length=128)),
("service_unit", models.CharField(blank=True, max_length=128)),
("file_path", models.CharField(blank=True, max_length=512)),
("category_override", models.CharField(blank=True, max_length=64)),
("event_type_override", models.CharField(blank=True, max_length=128)),
("enabled", models.BooleanField(db_index=True, default=True)),
("created_at", models.DateTimeField(default=timezone.now, editable=False)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"server",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="log_sources", to="servers.server"),
),
],
options={
"verbose_name": "Server log source",
"verbose_name_plural": "Server log sources",
"ordering": ["server_id", "kind", "name", "id"],
"indexes": [
models.Index(fields=["server", "enabled", "kind"], name="srvsrc_srv_en_kind_idx"),
models.Index(fields=["server", "service_unit"], name="srvsrc_srv_svc_idx"),
models.Index(fields=["server", "file_path"], name="servers_src_server_file_idx"),
],
},
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0011_serverlogsource"),
]
operations = [
migrations.AddField(
model_name="serverauditlog",
name="source_kind",
field=models.CharField(blank=True, db_index=True, max_length=16),
),
migrations.AddField(
model_name="serverauditlog",
name="source_name",
field=models.CharField(blank=True, db_index=True, max_length=512),
),
migrations.AddIndex(
model_name="serverauditlog",
index=models.Index(fields=["server", "source_kind", "event_at"], name="servers_audit_kind_event_idx"),
),
migrations.AddIndex(
model_name="serverauditlog",
index=models.Index(fields=["server", "source_name", "event_at"], name="servers_audit_name_event_idx"),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0012_serverauditlog_source_fields"),
]
operations = [
migrations.AddField(
model_name="server",
name="agent_api_token_hash",
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
]
@@ -0,0 +1,44 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0013_server_agent_api_token_hash"),
]
operations = [
migrations.AddField(
model_name="serverlogsource",
name="exclude_matches",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="serverlogsource",
name="include_matches",
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name="serverlogsource",
name="parser",
field=models.CharField(
choices=[
("none", "None"),
("syslog", "Syslog"),
("nginx_access", "Nginx Access"),
("json", "JSON"),
],
default="none",
max_length=32,
),
),
migrations.AlterField(
model_name="serverlogsource",
name="kind",
field=models.CharField(
choices=[("journal", "Journal"), ("service", "Service"), ("file", "File")],
db_index=True,
max_length=16,
),
),
]
+82
View File
@@ -28,6 +28,7 @@ class Server(models.Model):
agent_enrolled_at = models.DateTimeField(null=True, blank=True) agent_enrolled_at = models.DateTimeField(null=True, blank=True)
agent_cert_fingerprint = models.CharField(max_length=128, 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_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_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
last_ping_ms = models.PositiveIntegerField(null=True, blank=True) last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -189,3 +190,84 @@ class ServerAccount(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.system_username} ({self.server_id})" 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}"
+64 -9
View File
@@ -6,18 +6,73 @@ from django.utils import timezone
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
def user_can_shell(user, server, now=None) -> bool: def _active_access_requests(user, server, now):
if user.has_perm("servers.shell_server", server): return AccessRequest.objects.filter(
return True
if now is None:
now = timezone.now()
return (
AccessRequest.objects.filter(
requester=user, requester=user,
server=server, server=server,
status=AccessRequest.Status.APPROVED, 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() .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> </div>
<nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500"> <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 <a
href="{% url 'servers:detail' server.id %}" 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 %}" 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 Details
</a> </a>
{% endif %}
{% if can_logs %}
<a <a
href="{% url 'servers:audit' server.id %}" 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 %}" 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 Audit
</a> </a>
{% endif %}
{% if can_shell %} {% if can_shell %}
<a <a
href="{% url 'servers:shell' server.id %}" href="{% url 'servers:shell' server.id %}"
@@ -105,10 +109,20 @@
Shell Shell
</a> </a>
{% endif %} {% endif %}
{% if can_users %}
<a <a
href="{% url 'servers:settings' server.id %}" 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 %}" 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 Settings
</a> </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> </nav>
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}Admin • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Admin</h1>
<p class="mt-1 text-sm text-gray-500">Global administrative controls for Keywarden.</p>
</div>
</div>
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Users</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Servers</p>
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Pending Requests</p>
<p class="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p>
</article>
<article class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Active Grants</p>
<p class="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p>
</article>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Access request queue</h2>
<p class="mt-1 text-sm text-gray-500">Review and decide pending access requests.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<a href="{% url 'servers:server_admin' req.server.id %}" class="font-medium text-blue-700 hover:underline">
{{ req.server.display_name }}
</a>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Global admin utilities</h2>
<p class="mt-1 text-sm text-gray-500">Central administrative tooling for Keywarden operations.</p>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{% for utility in utilities %}
<a href="{{ utility.url }}" class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-blue-200 hover:bg-blue-50">
<h3 class="text-sm font-semibold text-gray-900">{{ utility.title }}</h3>
<p class="mt-1 text-xs text-gray-600">{{ utility.description }}</p>
</a>
{% endfor %}
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server admin pages</h2>
<p class="mt-1 text-sm text-gray-500">Open per-server administrative controls and user/account views.</p>
</div>
{% if servers %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Server</th>
<th class="px-3 py-2 font-semibold">Hostname / IP</th>
<th class="px-3 py-2 font-semibold text-right">Admin</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for server in servers %}
<tr>
<td class="px-3 py-2">{{ server.display_name }}</td>
<td class="px-3 py-2">{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}</td>
<td class="px-3 py-2 text-right">
<a href="{% url 'servers:server_admin' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Open admin
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No servers available.</p>
{% endif %}
</section>
</div>
{% endblock %}
+581 -25
View File
@@ -1,46 +1,602 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static tz %}
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %} {% 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 %} {% block content %}
<div class="space-y-6"> <div class="space-y-6">
{% include "servers/_header.html" %} {% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"> <section class="rounded-2xl border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-center gap-2 text-sm font-medium text-gray-500">
<div> <button
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2> type="button"
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p> 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 %}"
</div> data-audit-subpage="charts"
<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> Charts
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center"> </button>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700"> <button
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor"> type="button"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" /> 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 %}"
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle> data-audit-subpage="logs"
</svg> >
</div> Logs
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p> </button>
</div> </div>
</section> </section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"> <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">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2> <h2 class="text-lg font-semibold text-gray-900">Audit workspace</h2>
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p> <p class="mt-1 text-sm text-gray-500">Open multiple filtered panels side by side for service/file comparisons.</p>
</div> </div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span> <div 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>
<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> </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> </div>
</section> </section>
</div> </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 %} {% endblock %}
@@ -7,7 +7,7 @@
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1> <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> </div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700"> <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 {{ servers|length }} total
@@ -18,6 +18,7 @@
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %} {% 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"> <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-start justify-between">
<div class="flex items-center gap-3"> <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"> <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> </div>
</div> </div>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600"> <dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
<dt>Access until</dt> <dt>Access until</dt>
@@ -90,21 +90,50 @@
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
<dt>Last accessed</dt> <dt>Scopes</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if item.last_accessed %} <div class="flex flex-wrap justify-end gap-1">
{{ item.last_accessed|date:"M j, Y H:i" }} {% 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 %}
{% else %} {% 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 %}
{% endif %} </div>
</dd> </dd>
</div> </div>
</dl> </dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500"> <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> <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> <a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
{% elif item.access.can_logs %}
<a href="{% url 'servers:audit' item.server.id %}" class="font-semibold text-blue-700 hover:underline">Open audit</a>
{% elif item.access.can_shell %}
<a href="{% url 'servers:shell' item.server.id %}" class="font-semibold text-blue-700 hover:underline">Open shell</a>
{% endif %}
</div> </div>
{% 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> </article>
{% endfor %} {% endfor %}
</div> </div>
@@ -116,8 +145,8 @@
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle> <circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg> </svg>
</div> </div>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2> <h2 class="mt-4 text-lg font-semibold text-gray-900">No servers yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p> <p class="mt-2 text-sm text-gray-600">Create or enroll a server to start handling access requests.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% load tz %}
{% block title %}Admin • {{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Server admin utilities</h2>
<p class="mt-1 text-sm text-gray-500">Server-scoped administration and maintenance actions.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Edit server
</a>
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
Log sources
</a>
</div>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Agent enrolled</dt>
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Enrollment timestamp</dt>
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-gray-500">Last heartbeat</dt>
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
</div>
</dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Pending access requests</h2>
<p class="mt-1 text-sm text-gray-500">Approve or deny pending requests for this server.</p>
</div>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{{ pending_access_requests|length }} pending
</span>
</div>
{% if pending_access_requests %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">Requester</th>
<th class="px-3 py-2 font-semibold">Requested scopes</th>
<th class="px-3 py-2 font-semibold">Requested at</th>
<th class="px-3 py-2 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for req in pending_access_requests %}
<tr>
<td class="px-3 py-2">{{ req.requester.username }}</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-2">
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
</form>
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="deny">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No pending requests for this server.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Users with access</h2>
<p class="mt-1 text-sm text-gray-500">Effective access from approved requests and object permissions.</p>
</div>
{% if users_with_access %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">User</th>
<th class="px-3 py-2 font-semibold">Scopes</th>
<th class="px-3 py-2 font-semibold">Source</th>
<th class="px-3 py-2 font-semibold">Requests</th>
<th class="px-3 py-2 font-semibold">Expires</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for entry in users_with_access %}
<tr>
<td class="px-3 py-2">
<div class="font-medium text-gray-900">{{ entry.user.username }}</div>
<div class="text-xs text-gray-500">{{ entry.user.email|default:"—" }}</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if entry.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
{% if entry.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
{% if entry.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">
{% for label in entry.source_labels %}
<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">{{ label }}</span>
{% endfor %}
</td>
<td class="px-3 py-2">{{ entry.request_count }}</td>
<td class="px-3 py-2">
{% if entry.expires_at %}
{{ entry.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No users currently have access on this server.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="border-b border-gray-100 pb-4">
<h2 class="text-lg font-semibold text-gray-900">Server accounts</h2>
<p class="mt-1 text-sm text-gray-500">Accounts currently tracked by agent synchronization.</p>
</div>
{% if server_accounts %}
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
<tr>
<th class="px-3 py-2 font-semibold">System username</th>
<th class="px-3 py-2 font-semibold">User</th>
<th class="px-3 py-2 font-semibold">Present</th>
<th class="px-3 py-2 font-semibold">Last synced</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-gray-700">
{% for account in server_accounts %}
<tr>
<td class="px-3 py-2 font-medium text-gray-900">{{ account.system_username }}</td>
<td class="px-3 py-2">{{ account.user.username }}</td>
<td class="px-3 py-2">
{% if account.is_present %}
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700">Present</span>
{% else %}
<span class="inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700">Missing</span>
{% endif %}
</td>
<td class="px-3 py-2">{{ account.last_synced_at|utc|date:"M j, Y H:i:s" }} UTC</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-500">No synchronized server accounts yet.</p>
{% endif %}
</section>
</div>
{% endblock %}
+120 -11
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load tz %}
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %} {% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
@@ -7,22 +8,130 @@
{% include "servers/_header.html" %} {% include "servers/_header.html" %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"> <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> <div>
<h2 class="text-lg font-semibold text-gray-900">Settings</h2> <h2 class="text-lg font-semibold text-gray-900">Server status</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p> <p class="mt-1 text-sm text-gray-500">Current enrollment and telemetry posture for this host.</p>
</div> </div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div> </div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center"> <dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700"> <div class="flex items-center justify-between py-2">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <dt class="text-gray-500">Agent enrolled</dt>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" /> <dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div> </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>
<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> </section>
</div> </div>
{% endblock %} {% endblock %}
+39 -8
View File
@@ -240,6 +240,7 @@
var startButton = document.getElementById("shell-start"); var startButton = document.getElementById("shell-start");
var activeSocket = null; var activeSocket = null;
var activeTerm = null; var activeTerm = null;
var activeResizeHandler = null;
var popoutShell = document.getElementById("shell-popout-shell"); var popoutShell = document.getElementById("shell-popout-shell");
var isPopout = {{ is_popout|yesno:"true,false" }}; var isPopout = {{ is_popout|yesno:"true,false" }};
@@ -283,19 +284,40 @@
startButton.classList.toggle("hover:bg-blue-800", !isRunning); startButton.classList.toggle("hover:bg-blue-800", !isRunning);
} }
function stopTerminal() { function disposeTerminal() {
if (activeSocket) { 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 { try {
activeSocket.close(); activeSocket.close();
} catch (err) { } catch (err) {
// noop // noop
} }
} }
activeSocket = null;
if (termContainer) { if (termContainer) {
termContainer.dataset.started = "0"; termContainer.dataset.started = "0";
} }
activeSocket = null; if (opts.disposeTerminal) {
activeTerm = null; disposeTerminal();
}
setButtonState(false); setButtonState(false);
} }
@@ -303,6 +325,7 @@
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") { if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
return; return;
} }
disposeTerminal();
termContainer.dataset.started = "1"; termContainer.dataset.started = "1";
if (startButton) { if (startButton) {
startButton.disabled = true; startButton.disabled = true;
@@ -343,10 +366,18 @@
socket.onclose = function () { socket.onclose = function () {
term.write("\r\nSession closed.\r\n"); term.write("\r\nSession closed.\r\n");
if (activeSocket === socket) { 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) { term.onData(function (data) {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
socket.send(data); socket.send(data);
@@ -356,11 +387,11 @@
setButtonState(true); setButtonState(true);
if (isPopout) { if (isPopout) {
var onResize = function () { activeResizeHandler = function () {
sizePopoutTerminal(); sizePopoutTerminal();
fitTerminal(term); fitTerminal(term);
}; };
window.addEventListener("resize", onResize); window.addEventListener("resize", activeResizeHandler);
} }
} }
@@ -373,7 +404,7 @@
if (startButton) { if (startButton) {
startButton.addEventListener("click", function () { startButton.addEventListener("click", function () {
if (termContainer.dataset.started === "1") { if (termContainer.dataset.started === "1") {
stopTerminal(); stopTerminal({ disposeTerminal: true });
return; return;
} }
startTerminal(); startTerminal();
+1
View File
@@ -0,0 +1 @@
# Package marker for Django test discovery.
@@ -0,0 +1,172 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from guardian.shortcuts import assign_perm
from apps.access.models import AccessRequest
from apps.servers.models import Server, ServerAccount
from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access
class ServerAccessScopeTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="alice",
email="alice@example.com",
password="pass12345",
)
self.admin = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="pass12345",
)
self.server = Server.objects.create(
display_name="db-01",
hostname="db-01.example.test",
)
def _grant_perm(self, user, app_label: str, codename: str) -> None:
perm = Permission.objects.get(
content_type__app_label=app_label,
codename=codename,
)
user.user_permissions.add(perm)
def _create_access_request(self, **kwargs) -> AccessRequest:
payload = {
"requester": self.user,
"server": self.server,
"status": AccessRequest.Status.APPROVED,
"request_shell": False,
"request_logs": False,
"request_users": False,
}
payload.update(kwargs)
return AccessRequest.objects.create(**payload)
def test_scope_helpers_enforce_request_flags(self) -> None:
self._create_access_request(request_shell=True, request_logs=False, request_users=False)
self.assertTrue(user_can_shell(self.user, self.server))
self.assertFalse(user_can_logs(self.user, self.server))
self.assertFalse(user_can_users(self.user, self.server))
self.assertTrue(user_has_any_access(self.user, self.server))
def test_approved_request_without_scopes_does_not_grant_server_view(self) -> None:
self._create_access_request(request_shell=False, request_logs=False, request_users=False)
self.assertFalse(user_has_any_access(self.user, self.server))
self.assertFalse(self.user.has_perm("servers.view_server", self.server))
def test_manual_view_perm_allows_logs_and_users_without_access_request(self) -> None:
assign_perm("view_server", self.user, self.server)
self.assertTrue(user_can_logs(self.user, self.server))
self.assertTrue(user_can_users(self.user, self.server))
def test_audit_view_denied_when_logs_scope_not_granted(self) -> None:
self._create_access_request(request_users=True, request_logs=False)
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:audit", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
def test_detail_view_denied_when_users_scope_not_granted(self) -> None:
self._create_access_request(request_logs=True, request_users=False)
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:detail", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
def test_dashboard_request_access_creates_pending_request(self) -> None:
self._grant_perm(self.user, "access", "add_accessrequest")
self.client.login(username="alice", password="pass12345")
response = self.client.post(reverse("servers:request_access", args=[self.server.id]))
self.assertEqual(response.status_code, 302)
self.assertTrue(
AccessRequest.objects.filter(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
request_logs=True,
request_users=True,
).exists()
)
def test_admin_can_approve_pending_request(self) -> None:
pending = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
request_logs=True,
request_users=True,
)
self.client.login(username="admin", password="pass12345")
response = self.client.post(
reverse("servers:decide_access_request", args=[pending.id]),
{"action": "approve"},
)
self.assertEqual(response.status_code, 302)
pending.refresh_from_db()
self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
self.assertEqual(pending.decided_by_id, self.admin.id)
def test_admin_dashboard_requires_admin(self) -> None:
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:admin_dashboard"))
self.assertEqual(response.status_code, 404)
def test_admin_dashboard_lists_pending_requests(self) -> None:
pending = AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.PENDING,
request_shell=True,
)
self.client.login(username="admin", password="pass12345")
response = self.client.get(reverse("servers:admin_dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, pending.requester.username)
self.assertContains(response, "Access request queue")
def test_server_admin_requires_admin(self) -> None:
assign_perm("view_server", self.user, self.server)
self.client.login(username="alice", password="pass12345")
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
self.assertEqual(response.status_code, 404)
def test_server_admin_lists_users_and_server_accounts(self) -> None:
AccessRequest.objects.create(
requester=self.user,
server=self.server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
request_logs=True,
request_users=True,
)
ServerAccount.objects.create(
server=self.server,
user=self.user,
system_username="alice_1",
is_present=True,
)
self.client.login(username="admin", password="pass12345")
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Users with access")
self.assertContains(response, self.user.username)
self.assertContains(response, "Server accounts")
self.assertContains(response, "alice_1")
@@ -0,0 +1,295 @@
from __future__ import annotations
import json
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from guardian.shortcuts import assign_perm
from apps.servers.models import Server, ServerAuditLog, ServerLogSource
from keywarden.api.security import hash_agent_token
class AgentLogIngestionTests(TestCase):
def setUp(self) -> None:
super().setUp()
self.server = Server.objects.create(display_name="web-01", hostname="web-01.example.test")
self.agent_token = "test-agent-token"
self.server.agent_api_token_hash = hash_agent_token(self.agent_token)
self.server.save(update_fields=["agent_api_token_hash"])
self.agent_auth = f"Bearer {self.agent_token}"
def test_ingest_logs_persists_events(self) -> None:
payload = [
{
"timestamp": "2026-04-14T10:20:30Z",
"category": "access",
"event_type": "ssh.login.success",
"source_kind": "service",
"source_name": "sshd.service",
"priority": "6",
"username": "alice",
"source_ip": "203.0.113.10",
"message": "Accepted publickey for alice from 203.0.113.10",
"fields": {"_SYSTEMD_UNIT": "sshd.service", "PRIORITY": 6},
},
{
"timestamp": "not-a-time",
"category": "system",
"event_type": "system",
"source_ip": "not-an-ip",
"message": "fallback timestamp test",
"fields": {"FOO": "bar"},
},
]
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps(payload),
content_type="application/json",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["accepted"], 2)
self.assertEqual(ServerAuditLog.objects.filter(server=self.server).count(), 2)
success_log = ServerAuditLog.objects.get(event_type="ssh.login.success")
self.assertEqual(success_log.category, "access")
self.assertEqual(success_log.username, "alice")
self.assertEqual(success_log.source_kind, "service")
self.assertEqual(success_log.source_name, "sshd.service")
self.assertEqual(success_log.source_ip, "203.0.113.10")
self.assertEqual(success_log.fields.get("PRIORITY"), "6")
fallback_log = ServerAuditLog.objects.get(message="fallback timestamp test")
self.assertIsNone(fallback_log.source_ip)
self.assertLess(abs((timezone.now() - fallback_log.event_at).total_seconds()), 10)
def test_ingest_logs_returns_404_for_unknown_server(self) -> None:
response = self.client.post(
"/api/v1/agent/servers/999999/logs",
data=json.dumps([]),
content_type="application/json",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 404)
def test_ingest_logs_requires_agent_token(self) -> None:
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps([]),
content_type="application/json",
)
self.assertEqual(response.status_code, 401)
def test_ingest_logs_rejects_token_for_different_server(self) -> None:
other_server = Server.objects.create(display_name="web-02", hostname="web-02.example.test")
other_server.agent_api_token_hash = hash_agent_token("other-server-token")
other_server.save(update_fields=["agent_api_token_hash"])
response = self.client.post(
f"/api/v1/agent/servers/{self.server.id}/logs",
data=json.dumps([]),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer other-server-token",
)
self.assertEqual(response.status_code, 403)
def test_log_config_returns_server_sources(self) -> None:
ServerLogSource.objects.create(
server=self.server,
kind=ServerLogSource.Kind.SERVICE,
name="OpenSSH",
service_unit="ssh.service",
category_override="access",
event_type_override="ssh",
enabled=True,
)
ServerLogSource.objects.create(
server=self.server,
kind=ServerLogSource.Kind.FILE,
name="Auth File",
file_path="/var/log/auth.log",
category_override="auth",
event_type_override="file.line",
enabled=True,
)
ServerLogSource.objects.create(
server=self.server,
kind=ServerLogSource.Kind.JOURNAL,
name="Kernel Journal",
parser=ServerLogSource.Parser.NONE,
include_matches={"_TRANSPORT": ["kernel"]},
category_override="system",
event_type_override="kernel",
enabled=True,
)
response = self.client.get(
f"/api/v1/agent/servers/{self.server.id}/log-config",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload), 3)
kinds = {item["kind"] for item in payload}
self.assertEqual(kinds, {"service", "file", "journal"})
for item in payload:
self.assertTrue(item["source_id"])
self.assertIn("parser", item)
self.assertIn("include_matches", item)
self.assertIn("exclude_matches", item)
def test_log_config_returns_empty_when_no_sources(self) -> None:
response = self.client.get(
f"/api/v1/agent/servers/{self.server.id}/log-config",
HTTP_AUTHORIZATION=self.agent_auth,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
class ServerAuditViewTests(TestCase):
def setUp(self) -> None:
super().setUp()
User = get_user_model()
self.user = User.objects.create_user(
username="auditor",
email="auditor@example.com",
password="pass12345",
)
self.server = Server.objects.create(display_name="db-01", hostname="db-01.example.test")
assign_perm("view_server", self.user, self.server)
now = timezone.now()
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=5),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.success",
username="alice",
source_ip="203.0.113.10",
message="Accepted publickey for alice",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=4),
source_kind="service",
source_name="sshd.service",
category="access",
event_type="ssh.login.fail",
username="bob",
source_ip="198.51.100.5",
message="Failed password for bob",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=3),
source_kind="file",
source_name="/var/log/auth.log",
category="network",
event_type="network",
message="Interface eth0 changed",
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=2),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="203.0.113.10",
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
fields={"nginx.status": "200"},
)
ServerAuditLog.objects.create(
server=self.server,
event_at=now - timedelta(minutes=1),
source_kind="file",
source_name="/var/log/nginx/access.log",
category="access",
event_type="http.access",
source_ip="198.51.100.5",
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
fields={"nginx.status": "401"},
)
def test_audit_view_applies_filters(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{
"category": "access",
"event_type": "ssh.login.success",
"q": "Accepted",
},
)
self.assertEqual(response.status_code, 200)
logs = list(response.context["initial_panel"]["logs"])
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "ssh.login.success")
def test_audit_panel_view_supports_source_filters(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit_panel", args=[self.server.id]),
{
"source_kind": "file",
"source_name": "/var/log/auth.log",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "/var/log/auth.log")
self.assertNotContains(response, "ssh.login.success")
def test_audit_view_exposes_chart_context(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "charts"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["audit_subpage"], "charts")
charts = response.context["audit_charts"]
login = charts["login_attempts"]
self.assertEqual(login["total_attempts"], 2)
self.assertEqual(login["failed_attempts"], 1)
self.assertEqual(login["successful_attempts"], 1)
self.assertEqual(login["failure_rate_pct"], 50)
self.assertEqual(login["chart"]["drilldowns"][0], "view=logs&login_outcome=attempts")
status_labels = charts["http_status_codes"]["chart"]["labels"]
status_counts = charts["http_status_codes"]["chart"]["values"]
status_map = dict(zip(status_labels, status_counts))
self.assertEqual(status_map["401"], 1)
self.assertNotIn("200", status_map)
self.assertEqual(charts["http_status_codes"]["implicit_total"], 1)
def test_audit_view_drilldown_filters_logs_for_http_status(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "logs", "http_status": "401"},
)
self.assertEqual(response.status_code, 200)
logs = list(response.context["initial_panel"]["logs"])
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "http.access")
self.assertIn(" 401 ", logs[0].message)
def test_audit_view_drilldown_filters_logs_for_login_outcome(self) -> None:
self.client.login(username="auditor", password="pass12345")
response = self.client.get(
reverse("servers:audit", args=[self.server.id]),
{"view": "logs", "login_outcome": "success"},
)
self.assertEqual(response.status_code, 200)
logs = list(response.context["initial_panel"]["logs"])
self.assertEqual(len(logs), 1)
self.assertEqual(logs[0].event_type, "ssh.login.success")
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from django.test import SimpleTestCase, override_settings
from apps.servers.consumers import _build_ssh_command
class ShellSSHCommandTests(SimpleTestCase):
def test_build_command_uses_default_remote_shell(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], "/bin/bash")
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="/bin/bash -li")
def test_build_command_appends_configured_remote_shell(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-2:], ["/bin/bash", "-li"])
self.assertIn("alice@server.example.test", command)
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="")
def test_build_command_can_disable_explicit_remote_shell(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], "alice@server.example.test")
def test_build_command_can_disable_tty_and_set_timeout(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
remote_command="true",
force_tty=False,
connect_timeout=8,
)
self.assertNotIn("-tt", command)
self.assertIn("ConnectTimeout=8", command)
self.assertEqual(command[-1], "true")
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND='"/bin/bash -li')
def test_build_command_tolerates_invalid_shell_quoting(self):
command = _build_ssh_command(
key_path="/tmp/session_key",
cert_path="/tmp/session_key-cert.pub",
username="alice",
host="server.example.test",
)
self.assertEqual(command[-1], '"/bin/bash -li')
+9
View File
@@ -6,8 +6,17 @@ app_name = "servers"
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), 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>/", views.detail, name="detail"),
path("<int:server_id>/audit/", views.audit, name="audit"), 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>/shell/", views.shell, name="shell"),
path("<int:server_id>/settings/", views.settings, name="settings"), path("<int:server_id>/settings/", views.settings, name="settings"),
] ]
+853 -31
View File
@@ -1,67 +1,286 @@
from __future__ import annotations 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.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required 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.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 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.models import AccessRequest
from apps.access.permissions import sync_server_view_perm
from apps.keys.utils import render_system_username from apps.keys.utils import render_system_username
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
from apps.servers.models import Server, ServerAccount from apps.servers.models import Server, ServerAccount, ServerAuditLog
from apps.servers.permissions import user_can_shell 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/") @login_required(login_url="/accounts/login/")
def dashboard(request): def dashboard(request):
now = timezone.now() now = timezone.now()
server_qs = get_objects_for_user( user = request.user
request.user, if user.has_perm("access.add_accessrequest") or user.has_perm("servers.view_server"):
"servers.view_server", server_qs = Server.objects.all()
klass=Server, else:
accept_global_perms=False, server_qs = Server.objects.none()
)
access_qs = ( access_qs = (
AccessRequest.objects.select_related("server") AccessRequest.objects.filter(
.filter( requester=user,
requester=request.user,
status=AccessRequest.Status.APPROVED, status=AccessRequest.Status.APPROVED,
) )
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .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: for access in access_qs:
expires_at = access.expires_at server_id = access.server_id
if access.server_id not in expires_map: request_governed_server_ids.add(server_id)
expires_map[access.server_id] = expires_at 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 continue
current = expires_map[access.server_id] if access.expires_at is None or access.expires_at > current_expires:
if current is None: state["expires_at"] = access.expires_at
continue
if expires_at is None or expires_at > current: pending_access_qs = AccessRequest.objects.filter(
expires_map[access.server_id] = expires_at 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 = [] servers = []
for server in server_qs: 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( servers.append(
{ {
"server": server, "server": server,
"expires_at": expires_map.get(server.id), "expires_at": state["expires_at"] if has_access else None,
"last_accessed": None, "last_accessed": None,
"status": _build_server_status(server, now), "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 = { context = {
"pending_access_requests": pending_access_requests,
"utilities": utilities,
"servers": servers, "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/") @login_required(login_url="/accounts/login/")
@@ -71,6 +290,10 @@ def detail(request, server_id: int):
# any other server-specific work. # any other server-specific work.
server = _get_server_or_404(request, server_id) server = _get_server_or_404(request, server_id)
can_shell = user_can_shell(request.user, server, now) can_shell = user_can_shell(request.user, server, now)
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 = ( access = (
AccessRequest.objects.filter( AccessRequest.objects.filter(
@@ -78,6 +301,7 @@ def detail(request, server_id: int):
server_id=server_id, server_id=server_id,
status=AccessRequest.Status.APPROVED, 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)) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.order_by("-requested_at") .order_by("-requested_at")
.first() .first()
@@ -94,6 +318,9 @@ def detail(request, server_id: int):
"certificate_key_id": certificate_key_id, "certificate_key_id": certificate_key_id,
"active_tab": "details", "active_tab": "details",
"can_shell": can_shell, "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), "server_status": _build_server_status(server, now),
} }
return render(request, "servers/detail.html", context) 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) server = _get_server_or_404(request, server_id)
# We intentionally return a 404 on denied shell access to avoid # We intentionally return a 404 on denied shell access to avoid
# disclosing that the server exists but is restricted. # 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") raise Http404("Shell access not available")
_, system_username, certificate_key_id = _load_account_context(request, server) _, system_username, certificate_key_id = _load_account_context(request, server)
shell_target = server.hostname or server.ipv4 or server.ipv6 or "" 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, "cert_filename": cert_filename,
"active_tab": "shell", "active_tab": "shell",
"is_popout": request.GET.get("popout") == "1", "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), "server_status": _build_server_status(server, now),
} }
return render(request, "servers/shell.html", context) return render(request, "servers/shell.html", context)
@@ -138,28 +371,179 @@ def shell(request, server_id: int):
def audit(request, server_id: int): def audit(request, server_id: int):
now = timezone.now() now = timezone.now()
server = _get_server_or_404(request, server_id) 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 = { context = {
"server": server, "server": server,
"active_tab": "audit", "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), "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) 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/") @login_required(login_url="/accounts/login/")
def settings(request, server_id: int): def settings(request, server_id: int):
now = timezone.now() now = timezone.now()
server = _get_server_or_404(request, server_id) 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 = { context = {
"server": server, "server": server,
"active_tab": "settings", "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), "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) 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: def _get_server_or_404(request, server_id: int) -> Server:
# Centralized object lookup + permission gate. We raise 404 for both # Centralized object lookup + permission gate. We raise 404 for both
# missing objects and permission denials to reduce enumeration signals. # 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) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise Http404("Server not found") 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") raise Http404("Server not found")
return server return server
@@ -228,3 +612,441 @@ def _build_server_status(server: Server, now):
"age_short": age_short, "age_short": age_short,
"heartbeat_at": heartbeat_at, "heartbeat_at": heartbeat_at,
} }
def _parse_filter_datetime(value: str, end_of_day: bool) -> datetime | None:
if not value:
return None
parsed_dt = parse_datetime(value)
if parsed_dt is not None:
if timezone.is_naive(parsed_dt):
return timezone.make_aware(parsed_dt, timezone.get_current_timezone())
return parsed_dt
parsed_date = parse_date(value)
if parsed_date is None:
return None
parsed_time = time.max if end_of_day else time.min
naive = datetime.combine(parsed_date, parsed_time)
return timezone.make_aware(naive, timezone.get_current_timezone())
def _build_audit_summary(server: Server, now, logs=None) -> dict:
logs_qs = logs if logs is not None else ServerAuditLog.objects.filter(server=server)
last_event_at = logs_qs.values_list("event_at", flat=True).first()
last_ingest_at = logs_qs.values_list("received_at", flat=True).first()
top_categories = list(
logs_qs.values("category")
.annotate(total=Count("id"))
.order_by("-total", "category")[:5]
)
return {
"total_count": logs_qs.count(),
"recent_24h_count": logs_qs.filter(event_at__gte=now - timedelta(hours=24)).count(),
"last_event_at": last_event_at,
"last_ingest_at": last_ingest_at,
"top_categories": top_categories,
}
def _build_audit_chart_context(server: Server) -> dict:
logs_qs = ServerAuditLog.objects.filter(server=server)
login_attempt_filter = _login_attempt_filter()
failed_login_filter = _failed_login_filter()
success_login_filter = _success_login_filter()
total_login_attempts = logs_qs.filter(login_attempt_filter).count()
failed_login_attempts = logs_qs.filter(failed_login_filter).count()
successful_login_attempts = logs_qs.filter(success_login_filter).count()
login_chart = _build_chart_payload(
items=[
{
"label": "Total attempts",
"count": total_login_attempts,
"color": "#2563eb",
"drilldown": _build_audit_drilldown_query(login_outcome="attempts"),
},
{
"label": "Failed attempts",
"count": failed_login_attempts,
"color": "#e11d48",
"drilldown": _build_audit_drilldown_query(login_outcome="failed"),
},
{
"label": "Successful attempts",
"count": successful_login_attempts,
"color": "#16a34a",
"drilldown": _build_audit_drilldown_query(login_outcome="success"),
},
],
max_items=3,
)
failure_rate_pct = 0
if total_login_attempts > 0:
failure_rate_pct = int(round((failed_login_attempts / total_login_attempts) * 100))
http_status_counts: Counter[str] = Counter()
implicit_status_total = 0
http_logs = logs_qs.filter(
Q(event_type="http.access")
| Q(source_name__icontains="nginx")
| Q(message__icontains="HTTP/")
).values_list("fields", "message", "raw")
for fields, message, raw in http_logs.iterator(chunk_size=500):
status_code = _extract_http_status_code(fields, message, raw)
if status_code:
if status_code == "200" or status_code.startswith("1"):
implicit_status_total += 1
continue
http_status_counts[status_code] += 1
http_status_chart = _build_chart_payload(
items=[
{
"label": status_code,
"count": total,
"color": _http_status_color(status_code),
"drilldown": _build_audit_drilldown_query(http_status=status_code),
}
for status_code, total in sorted(http_status_counts.items(), key=lambda item: (-item[1], item[0]))
],
max_items=12,
)
failed_source_ip_rows = (
logs_qs.filter(failed_login_filter)
.exclude(source_ip__isnull=True)
.exclude(source_ip="")
.values("source_ip")
.annotate(total=Count("id"))
.order_by("-total", "source_ip")[:8]
)
failed_source_ip_chart = _build_chart_payload(
items=[
{
"label": row["source_ip"],
"count": row["total"],
"color": "#d97706",
"drilldown": _build_audit_drilldown_query(login_outcome="failed", source_ip=row["source_ip"]),
}
for row in failed_source_ip_rows
],
max_items=8,
)
security_event_rows = (
logs_qs.filter(Q(category__in=["access", "auth"]) | Q(event_type__startswith="ssh."))
.values("event_type")
.annotate(total=Count("id"))
.order_by("-total", "event_type")[:8]
)
security_event_chart = _build_chart_payload(
items=[
{
"label": row["event_type"] or "unknown",
"count": row["total"],
"color": "#4f46e5",
"drilldown": _build_audit_drilldown_query(event_type=(row["event_type"] or "unknown")),
}
for row in security_event_rows
],
max_items=10,
)
return {
"login_attempts": {
"total_attempts": total_login_attempts,
"failed_attempts": failed_login_attempts,
"successful_attempts": successful_login_attempts,
"failure_rate_pct": failure_rate_pct,
"chart": login_chart,
},
"http_status_codes": {
"total": sum(http_status_counts.values()),
"implicit_total": implicit_status_total,
"chart": http_status_chart,
},
"failed_source_ips": {
"total": sum(failed_source_ip_chart["values"]),
"chart": failed_source_ip_chart,
},
"security_event_types": {
"total": sum(security_event_chart["values"]),
"chart": security_event_chart,
},
}
def _extract_http_status_code(fields, message: str, raw: str) -> str:
if isinstance(fields, dict):
for key in ("nginx.status", "status", "status_code"):
candidate = str(fields.get(key, "")).strip()
if _is_http_status_code(candidate):
return candidate
for text in (message, raw):
if not text:
continue
match = HTTP_STATUS_PATTERN.search(text)
if not match:
continue
candidate = match.group(1)
if _is_http_status_code(candidate):
return candidate
return ""
def _is_http_status_code(value: str) -> bool:
if len(value) != 3 or not value.isdigit():
return False
return 100 <= int(value) <= 599
def _http_status_color(status_code: str) -> str:
if not status_code:
return "#6b7280"
family = status_code[0]
if family == "2":
return "#16a34a"
if family == "3":
return "#2563eb"
if family == "4":
return "#d97706"
if family == "5":
return "#dc2626"
return "#6b7280"
def _build_chart_payload(items: list[dict], *, max_items: int) -> dict:
labels: list[str] = []
values: list[int] = []
colors: list[str] = []
drilldowns: list[str] = []
for item in items:
count = int(item.get("count", 0))
if count <= 0:
continue
labels.append(str(item.get("label", "")))
values.append(count)
colors.append(str(item.get("color", "#2563eb")))
drilldowns.append(str(item.get("drilldown", "")))
if len(labels) >= max_items:
break
return {
"labels": labels,
"values": values,
"colors": colors,
"drilldowns": drilldowns,
}
def _build_audit_drilldown_query(**filters: str) -> str:
params: dict[str, str] = {"view": "logs"}
for key, value in filters.items():
text = (value or "").strip()
if not text:
continue
params[key] = text
return urlencode(params)
def _login_attempt_filter() -> Q:
return (
Q(event_type__in=SSH_LOGIN_EVENT_TYPES)
| (Q(category="access") & (Q(message__icontains="Accepted ") | Q(message__icontains="Failed password")))
)
def _success_login_filter() -> Q:
return Q(event_type="ssh.login.success") | Q(message__icontains="Accepted ")
def _failed_login_filter() -> Q:
return (
Q(event_type="ssh.login.fail")
| Q(message__icontains="Failed password")
| Q(message__icontains="authentication failure")
| Q(message__icontains="invalid user")
)
def _build_audit_panel_context(request, server: Server, panel_action: str) -> dict:
logs_qs = ServerAuditLog.objects.filter(server=server)
all_logs_qs = logs_qs
category = request.GET.get("category", "").strip()
event_type = request.GET.get("event_type", "").strip()
priority = request.GET.get("priority", "").strip()
source_kind = request.GET.get("source_kind", "").strip()
source_name = request.GET.get("source_name", "").strip()
username = request.GET.get("username", "").strip()
source_ip = request.GET.get("source_ip", "").strip()
q = request.GET.get("q", "").strip()
since = request.GET.get("since", "").strip()
until = request.GET.get("until", "").strip()
login_outcome = (request.GET.get("login_outcome") or "").strip().lower()
if login_outcome not in LOGIN_OUTCOME_OPTIONS:
login_outcome = ""
http_status = (request.GET.get("http_status") or "").strip()
if not _is_http_status_code(http_status):
http_status = ""
line_limit = _parse_line_limit(request.GET.get("line_limit", ""))
if category:
logs_qs = logs_qs.filter(category=category)
if event_type:
logs_qs = logs_qs.filter(event_type=event_type)
if priority:
logs_qs = logs_qs.filter(priority=priority)
if source_kind:
logs_qs = logs_qs.filter(source_kind=source_kind)
if source_name:
logs_qs = logs_qs.filter(source_name=source_name)
if username:
logs_qs = logs_qs.filter(username__icontains=username)
if source_ip:
logs_qs = logs_qs.filter(source_ip__icontains=source_ip)
if q:
logs_qs = logs_qs.filter(
Q(message__icontains=q)
| Q(raw__icontains=q)
| Q(principal__icontains=q)
| Q(session_id__icontains=q)
| Q(username__icontains=q)
| Q(event_type__icontains=q)
| Q(source_name__icontains=q)
)
if login_outcome == "attempts":
logs_qs = logs_qs.filter(_login_attempt_filter())
elif login_outcome == "success":
logs_qs = logs_qs.filter(_success_login_filter())
elif login_outcome == "failed":
logs_qs = logs_qs.filter(_failed_login_filter())
if http_status:
status_token = f" {http_status} "
quoted_status_token = f'" {http_status} '
logs_qs = logs_qs.filter(event_type="http.access").filter(
Q(message__icontains=quoted_status_token)
| Q(raw__icontains=quoted_status_token)
| Q(message__icontains=status_token)
| Q(raw__icontains=status_token)
)
since_dt = _parse_filter_datetime(since, end_of_day=False)
if since_dt:
logs_qs = logs_qs.filter(event_at__gte=since_dt)
until_dt = _parse_filter_datetime(until, end_of_day=True)
if until_dt:
logs_qs = logs_qs.filter(event_at__lte=until_dt)
filtered_count = logs_qs.count()
paginator = Paginator(logs_qs, line_limit)
page_obj = paginator.get_page(request.GET.get("page") or 1)
page_logs = list(page_obj.object_list)
for log in page_logs:
source_label = (
(log.source_name or "").strip()
or (log.unit or "").strip()
or (log.hostname or "").strip()
or (log.source_kind or "").strip()
or "unknown"
)
compact_message = ((log.message or "").strip() or (log.raw or "").strip())
compact_message = " ".join(compact_message.split())
if not compact_message:
compact_message = "—"
if log.priority:
compact_message = f"[p{log.priority}] {compact_message}"
log.compact_source = source_label
log.compact_line = compact_message
query = request.GET.copy()
query.pop("page", None)
query_without_page = query.urlencode()
event_type_options_qs = all_logs_qs
if category:
event_type_options_qs = event_type_options_qs.filter(category=category)
source_name_options_qs = all_logs_qs
if source_kind:
source_name_options_qs = source_name_options_qs.filter(source_kind=source_kind)
source_name_options = set(source_name_options_qs.order_by().values_list("source_name", flat=True).distinct())
configured_sources = server.log_sources.filter(enabled=True)
if source_kind:
configured_sources = configured_sources.filter(kind=source_kind)
for configured in configured_sources:
configured_name = (
(configured.name or "").strip()
or (configured.service_unit or "").strip()
or (configured.file_path or "").strip()
)
if configured_name:
source_name_options.add(configured_name)
source_kind_options = set(all_logs_qs.order_by().values_list("source_kind", flat=True).distinct())
source_kind_options.update(server.log_sources.filter(enabled=True).order_by().values_list("kind", flat=True))
filters_active = any(
[
category,
event_type,
priority,
source_kind,
source_name,
username,
source_ip,
q,
since,
until,
login_outcome,
http_status,
]
)
reset_href = f"{panel_action}?line_limit={line_limit}"
return {
"logs": page_logs,
"page_obj": page_obj,
"query_without_page": query_without_page,
"panel_action": panel_action,
"line_limit": line_limit,
"line_limit_options": AUDIT_LINE_LIMIT_OPTIONS,
"filters_expanded": filters_active,
"reset_href": reset_href,
"filtered_count": filtered_count,
"filter_values": {
"category": category,
"event_type": event_type,
"priority": priority,
"source_kind": source_kind,
"source_name": source_name,
"username": username,
"source_ip": source_ip,
"q": q,
"since": since,
"until": until,
"login_outcome": login_outcome,
"http_status": http_status,
"line_limit": str(line_limit),
},
"filter_options": {
"categories": sorted(all_logs_qs.order_by().values_list("category", flat=True).distinct()),
"event_types": sorted(event_type_options_qs.order_by().values_list("event_type", flat=True).distinct()),
"priorities": sorted(all_logs_qs.order_by().values_list("priority", flat=True).distinct()),
"source_kinds": sorted(source_kind_options),
"source_names": sorted(source_name_options),
},
}
def _parse_line_limit(raw_value) -> int:
try:
parsed = int(str(raw_value).strip())
except (TypeError, ValueError):
return DEFAULT_AUDIT_LINE_LIMIT
if parsed in AUDIT_LINE_LIMIT_OPTIONS:
return parsed
return DEFAULT_AUDIT_LINE_LIMIT
def _next_redirect_target(request, server_id: int) -> str:
target = (request.POST.get("next") or "").strip()
if target.startswith("/"):
return target
return reverse("servers:server_admin", args=[server_id])
+5
View File
@@ -128,6 +128,11 @@ def build_router() -> Router:
server = Server.objects.get(id=payload.server_id) server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Server not found") 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( access_request = AccessRequest(
requester=request.user, requester=request.user,
server=server, server=server,
+301 -28
View File
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta import secrets
from datetime import datetime, timedelta, timezone as dt_timezone
from typing import List, Optional from typing import List, Optional
from cryptography import x509 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.exceptions import ValidationError
from django.core.validators import validate_ipv4_address, validate_ipv6_address from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.dateparse import parse_datetime
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from ninja import Body, Router, Schema from ninja import Body, Router, Schema
@@ -17,6 +20,7 @@ from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from apps.access.models import AccessRequest
from apps.core.rbac import require_perms from apps.core.rbac import require_perms
from apps.keys.certificates import get_active_ca from apps.keys.certificates import get_active_ca
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
@@ -25,10 +29,13 @@ from apps.servers.models import (
AgentCertificateAuthority, AgentCertificateAuthority,
EnrollmentToken, EnrollmentToken,
Server, Server,
ServerAuditLog,
ServerAccount, ServerAccount,
ServerLogSource,
hostname_validator, hostname_validator,
) )
from apps.telemetry.models import TelemetryEvent from apps.telemetry.models import TelemetryEvent
from keywarden.api.security import AgentPrincipal, AgentTokenAuth, hash_agent_token
class AuthorizedKeyOut(Schema): class AuthorizedKeyOut(Schema):
@@ -82,12 +89,15 @@ class AgentEnrollOut(Schema):
server_id: str server_id: str
client_cert_pem: str client_cert_pem: str
ca_cert_pem: str ca_cert_pem: str
agent_api_token: str
class LogEventIn(Schema): class LogEventIn(Schema):
timestamp: str timestamp: str
category: str category: str
event_type: str event_type: str
source_kind: Optional[str] = None
source_name: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
priority: Optional[str] = None priority: Optional[str] = None
hostname: Optional[str] = None hostname: Optional[str] = None
@@ -105,6 +115,19 @@ class LogIngestOut(Schema):
accepted: int 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): class AgentHeartbeatIn(Schema):
host: Optional[str] = None host: Optional[str] = None
ipv4: Optional[str] = None ipv4: Optional[str] = None
@@ -114,6 +137,7 @@ class AgentHeartbeatIn(Schema):
def build_router() -> Router: def build_router() -> Router:
router = Router() router = Router()
agent_auth = AgentTokenAuth()
@router.post("/enroll", response=AgentEnrollOut, auth=None) @router.post("/enroll", response=AgentEnrollOut, auth=None)
@csrf_exempt @csrf_exempt
@@ -164,10 +188,19 @@ def build_router() -> Router:
token.mark_used(server) token.mark_used(server)
token.save(update_fields=["used_at", "server"]) token.save(update_fields=["used_at", "server"])
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id) 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_enrolled_at = timezone.now()
server.agent_cert_fingerprint = fingerprint server.agent_cert_fingerprint = fingerprint
server.agent_cert_serial = serial 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: except IntegrityError:
raise HttpError(409, "Server already enrolled") raise HttpError(409, "Server already enrolled")
@@ -175,6 +208,7 @@ def build_router() -> Router:
server_id=str(server.id), server_id=str(server.id),
client_cert_pem=cert_pem, client_cert_pem=cert_pem,
ca_cert_pem=ca_pem, ca_cert_pem=ca_pem,
agent_api_token=agent_api_token,
) )
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut]) @router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
@@ -183,7 +217,7 @@ def build_router() -> Router:
Auth: required (admin/operator via API). Auth: required (admin/operator via API).
Permissions: requires view access to servers and keys. 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. the exact key list the agent should deploy to the server.
Rationale: this is the policy enforcement point for per-user access. Rationale: this is the policy enforcement point for per-user access.
""" """
@@ -193,7 +227,7 @@ def build_router() -> Router:
"keys.view_sshkey", "keys.view_sshkey",
) )
server = _get_server_or_404(server_id) 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) key_map = _key_map_for_users(users)
output: list[AuthorizedKeyOut] = [] output: list[AuthorizedKeyOut] = []
for user in users: for user in users:
@@ -209,16 +243,17 @@ def build_router() -> Router:
) )
return output 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): def account_access(request: HttpRequest, server_id: int):
"""List accounts that should exist on a server. """List accounts that should exist on a server.
Auth: mTLS expected at the edge (no session/JWT). Auth: per-agent bearer token issued at enrollment.
Behavior: resolves active users with server object perms and their keys. Behavior: resolves active users with approved shell/users scopes.
Rationale: drives agent-side account provisioning. Rationale: drives agent-side account provisioning.
""" """
_require_agent_access(request, server_id)
server = _get_server_or_404(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 [ return [
AccountAccessOut( AccountAccessOut(
user_id=user.id, user_id=user.id,
@@ -230,29 +265,31 @@ def build_router() -> Router:
for user in users 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 @csrf_exempt
def ssh_ca(request: HttpRequest, server_id: int): def ssh_ca(request: HttpRequest, server_id: int):
"""Return the active SSH user CA public key for agents. """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) _ = _get_server_or_404(server_id)
ca = get_active_ca() ca = get_active_ca()
if not ca.public_key: if not ca.public_key:
raise HttpError(404, "SSH CA not configured") raise HttpError(404, "SSH CA not configured")
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint} 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 @csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)): def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""Record an agent sync report for a server. """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. Behavior: stores a telemetry event with counts of applied/revoked keys.
Rationale: provides an audit trail of enforcement actions without Rationale: provides an audit trail of enforcement actions without
requiring full log ingestion for every sync cycle. requiring full log ingestion for every sync cycle.
""" """
_require_agent_access(request, server_id)
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -273,34 +310,94 @@ def build_router() -> Router:
_update_server_accounts(server, payload.accounts) _update_server_accounts(server, payload.accounts)
return SyncReportOut(status="ok") 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 @csrf_exempt
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)): def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
"""Accept log batches from agents for audit collection. """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. Behavior: accepts structured log events for later storage and indexing.
Storage: raw logs are persisted separately per-server (SQLite shards), Storage: events are persisted in the primary datastore and indexed per
not in the primary Postgres database. server for filtering in the server audit view.
Rationale: this is the ingestion pipe for security audit logging. Rationale: this is the ingestion pipe for security audit logging.
""" """
_require_agent_access(request, server_id)
try: try:
Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Server not found") raise HttpError(404, "Server not found")
# TODO: enqueue to Valkey and persist to SQLite slices. if not payload:
return LogIngestOut(status="accepted", accepted=len(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 @csrf_exempt
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)): def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
"""Update server host metadata (hostname/IPs) reported by the agent. """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). Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
Conflict: unique constraints are enforced; conflicts return 409. Conflict: unique constraints are enforced; conflicts return 409.
Rationale: keeps the server inventory accurate without manual edits. Rationale: keeps the server inventory accurate without manual edits.
""" """
_require_agent_access(request, server_id)
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -343,17 +440,69 @@ def _get_server_or_404(server_id: int) -> Server:
raise HttpError(404, "Server not found") raise HttpError(404, "Server not found")
def _resolve_access_users(server: Server) -> list: def _issue_agent_api_token() -> str:
users = list( # Opaque, high-entropy token; only SHA-256 hash is stored server-side.
get_users_with_perms( 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, server,
only_with_perms_in=["view_server"], only_with_perms_in=["view_server"],
with_group_users=True, with_group_users=True,
with_superusers=False, with_superusers=False,
) ):
) if not getattr(user, "is_active", False):
active = [user for user in users if getattr(user, "is_active", False)] continue
return sorted(active, key=lambda user: (user.username or "", user.id)) # 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]]: 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 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() router = build_router()
+47
View File
@@ -1,13 +1,19 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from dataclasses import dataclass
from secrets import compare_digest
from typing import Optional from typing import Optional
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest from django.http import HttpRequest
from ninja.security import HttpBearer from ninja.security import HttpBearer
from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed
from apps.servers.models import Server
class JWTAuth(HttpBearer): class JWTAuth(HttpBearer):
""" """
@@ -28,3 +34,44 @@ class JWTAuth(HttpBearer):
return None return None
class AgentTokenAuth(HttpBearer):
"""
Auth via Authorization: Bearer <agent token>.
Validates enrollment-issued per-server tokens first, with an optional
global fallback from KEYWARDEN_AGENT_API_TOKEN for migration.
"""
def authenticate(self, request: HttpRequest, token: str) -> Optional["AgentPrincipal"]:
candidate = _normalize_agent_token(token)
if not candidate:
return None
token_hash = hash_agent_token(candidate)
server = Server.objects.filter(agent_api_token_hash=token_hash).only("id").first()
if server:
return AgentPrincipal(server_id=server.id, mode="server-token")
configured = _normalize_agent_token(getattr(settings, "KEYWARDEN_AGENT_API_TOKEN", ""))
if configured and compare_digest(candidate, configured):
return AgentPrincipal(server_id=None, mode="global-token")
return None
@dataclass(frozen=True)
class AgentPrincipal:
server_id: Optional[int]
mode: str
def _normalize_agent_token(value: object) -> str:
token = str(value or "").strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
return token
def hash_agent_token(value: str) -> str:
normalized = _normalize_agent_token(value)
if not normalized:
return ""
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+18
View File
@@ -189,6 +189,9 @@ CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}, "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
} }
TIME_ZONE = "UTC"
USE_TZ = True
SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default" 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_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_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_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 = os.getenv(
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}" "KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
) )
KEYWARDEN_HEARTBEAT_STALE_SECONDS = int(os.getenv("KEYWARDEN_HEARTBEAT_STALE_SECONDS", "120")) 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_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL) CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
+2
View File
@@ -517,6 +517,8 @@ function dateTimeShortcutsOverlay() {
childList: true, childList: true,
subtree: true, subtree: true,
}); });
findCalendars();
}; };
/************************************************************* /*************************************************************
+71 -3
View File
@@ -15,13 +15,41 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
{% tailwind_css %} {% 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 %} {% block extra_head %}{% endblock %}
</head> </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']"> <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"> <div class="min-h-screen flex flex-col">
{% if not is_popout %} {% if not is_popout %}
<header class="border-b border-gray-200 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/75"> <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"> <div class="flex flex-wrap items-center justify-between">
<a href="/" class="flex items-center gap-3"> <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"> <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 Profile
</a> </a>
</li> </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 %} {% endif %}
</ul> </ul>
</div> </div>
@@ -88,13 +126,13 @@
</header> </header>
{% endif %} {% 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 %} {% block content %}{% endblock %}
</main> </main>
{% if not is_popout %} {% if not is_popout %}
<footer class="border-t border-gray-200 bg-white"> <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"> <div class="flex items-center gap-2">
<span class="font-medium text-gray-700">Keywarden</span> <span class="font-medium text-gray-700">Keywarden</span>
<span class="text-gray-300">•</span> <span class="text-gray-300">•</span>
@@ -109,6 +147,36 @@
</footer> </footer>
{% endif %} {% endif %}
</div> </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> <script src="https://cdn.jsdelivr.net/npm/flowbite@4.0.1/dist/flowbite.min.js"></script>
</body> </body>
</html> </html>
+1
View File
@@ -29,6 +29,7 @@ services:
environment: environment:
- DJANGO_SETTINGS_MODULE=keywarden.settings.dev - DJANGO_SETTINGS_MODULE=keywarden.settings.dev
- PYTHONPATH=/app - PYTHONPATH=/app
- TZ=UTC
env_file: env_file:
- .env - .env