Compare commits

...

19 Commits

Author SHA1 Message Date
70d0e808f8 Updated agent to include ping in heartbeat. 2026-02-03 15:24:11 +00:00
bebaaf1367 Refactor to Flowbite for UI 2026-02-03 09:54:49 +00:00
962ba27679 Commented terminal files 2026-02-03 09:33:49 +00:00
f54cc3f09b Changed ephemeral key to 30m lifespan; keys stored in /dev/shm; explicit 0600 perms; delete keys when session opens. 2026-02-03 09:17:15 +00:00
667b02f0c3 Ephemeral keys for xterm.js. Initial rework of audit logging. All endpoints now return a 401 regardless of presence if not logged in. 2026-02-03 08:26:37 +00:00
3e17d6412c ASGI via Daphne for websockets, WSGI via Gunicorn. Implemented xterm.js for shell proxy to target servers. 2026-01-27 00:33:53 +00:00
56caa194ec Cleaned up object perms 2026-01-26 23:55:58 +00:00
9cf782ffd6 Added certificate regeneration. Refactored server dashboard. 2026-01-26 23:36:12 +00:00
664e7be9f0 Certificate generation and sync, implemented proper grant and revocation flows. Pubkey uploading. Added openssh-client to Dockerfile 2026-01-26 23:27:18 +00:00
cdaceb1cf7 Display username on client panel 2026-01-26 17:18:31 +00:00
43bff4513a object‑permission–driven server access; agent‑managed account provisioning with presence reporting 2026-01-26 17:03:44 +00:00
ed2f921b0f Fixed index name too long. Added icon and fixed unfold branding 2026-01-26 16:41:16 +00:00
e693a7616c GDPR Compliant erasure requests 2026-01-26 13:50:21 +00:00
548681face Improved API docs, removed DELETE endpoint from user 2026-01-26 13:42:08 +00:00
c115f41dac Switched to Redoc 2026-01-26 13:31:08 +00:00
69802f3ece Agent retries on connection loss, sends connection info (v4 v6) Uses system CA for mTLS. Removed server endpoints. 2026-01-26 01:13:51 +00:00
e7d20360a2 Django->6.0.1 Django Ninja->1.5.2 mozilla-django-oidc->5.0.2 django-guardian->3.2 gunicorn->24.1 django-unfold->0.76 2026-01-26 00:43:49 +00:00
1d0c075d68 Attempting to resolve unfold form inconsistencies. 2026-01-26 00:21:49 +00:00
b95084ddc3 Linux agent functional. Added new client-facing server panel. Removed deferred pydantic annotations. 2026-01-25 23:08:40 +00:00
140 changed files with 7187 additions and 1200 deletions

View File

@@ -13,3 +13,35 @@ Authentication:
Notes: Notes:
- Base URL for v1 endpoints is `/api/v1`. - Base URL for v1 endpoints is `/api/v1`.
- Admin-only routes return `403 Forbidden` when the token user is not staff/superuser. - Admin-only routes return `403 Forbidden` when the token user is not staff/superuser.
Example: update server display name (admin-only)
PATCH `/api/v1/servers/{server_id}`
```json
{
"display_name": "Keywarden Prod"
}
```
## SSH user certificates (OpenSSH CA)
Keywarden signs user SSH keys with an OpenSSH certificate authority. The flow is:
- User uploads a public key (`POST /api/v1/keys`).
- Server signs the key using the active user CA.
- Certificate is stored server-side and can be downloaded by the user.
Endpoints:
- `POST /api/v1/keys/{key_id}/certificate` issues (or re-issues) a certificate.
- `GET /api/v1/keys/{key_id}/certificate` downloads the certificate.
- `GET /api/v1/keys/{key_id}/certificate.sha256` downloads a sha256 hash file.
Agent endpoints (mTLS):
- `GET /api/v1/agent/servers/{server_id}/ssh-ca` returns the CA public key for agent install.
- `GET /api/v1/agent/servers/{server_id}/accounts` returns account + system username (no raw keys).
Configuration:
- `KEYWARDEN_USER_CERT_VALIDITY_DAYS` controls certificate lifetime (default: 30 days).
- `KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE` controls account name derivation.
Note: `ssh-keygen` must be available on the Keywarden server to sign certificates.

View File

@@ -17,6 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \ libpq-dev \
curl \ curl \
openssl \ openssl \
openssh-client \
nginx \ nginx \
nodejs \ nodejs \
npm \ npm \
@@ -55,7 +56,7 @@ COPY nginx/configs/options-* /etc/nginx/
COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf
RUN python manage.py collectstatic --noinput RUN python manage.py collectstatic --noinput
RUN chmod +x /app/entrypoint.sh /app/scripts/gunicorn.sh RUN chmod +x /app/entrypoint.sh /app/scripts/gunicorn.sh /app/scripts/daphne.sh
# ============================================= # =============================================
# 5. Create users for services # 5. Create users for services

37
TODO.md Normal file
View File

@@ -0,0 +1,37 @@
Next steps:
Certificate Generation:
- User account is created
- User can input SSH pubkey into profile page
- Keywarden creates signed SSH Certificate from User's pubkey and Keywarden CA
Grant:
- User requests access to target server
- Access request approved
- User has linux account created and has key / cert trusted by target server
- User can log into account
Revocation:
- User has access expire or revoked
- Keywarden removes key / cert from target server, or invalidates on Keywarden's side
- Keywarden removes object permissions
- User cannot access server anymore
Permissions:
Administrator:
- Everything
Auditor:
- Can exclusively view audit logs of servers they have access to via request.
User:
Access Requests:
- Can use Shell?
- Can view logs?
- Can have user account?

1
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
keywarden-agent

View File

@@ -1,6 +1,8 @@
TODO: Move to boris/keywarden-agent. In main repo for now for development.
# keywarden-agent # keywarden-agent
Minimal Go agent scaffold for Keywarden. Minimal Go agent for Keywarden.
## Build ## Build
@@ -20,4 +22,6 @@ You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environ
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.
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.
See `config.example.json`. See `config.example.json`.

View File

@@ -18,6 +18,7 @@ import (
"keywarden/agent/internal/client" "keywarden/agent/internal/client"
"keywarden/agent/internal/config" "keywarden/agent/internal/config"
"keywarden/agent/internal/host"
"keywarden/agent/internal/logs" "keywarden/agent/internal/logs"
"keywarden/agent/internal/version" "keywarden/agent/internal/version"
) )
@@ -74,12 +75,23 @@ func main() {
} }
func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config) { func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config) {
if err := apiClient.SyncAccounts(ctx, cfg.ServerID); err != nil { if err := reportHost(ctx, apiClient, cfg); err != nil {
if client.IsRetriable(err) {
log.Printf("host update deferred; will retry: %v", err)
} else {
log.Printf("host update error: %v", err)
}
}
if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
log.Printf("sync accounts error: %v", err) log.Printf("sync accounts error: %v", err)
} }
if err := shipLogs(ctx, apiClient, cfg); err != nil { if err := shipLogs(ctx, apiClient, cfg); err != nil {
if client.IsRetriable(err) {
log.Printf("log shipping deferred; will retry: %v", err)
} else {
log.Printf("log shipping error: %v", err) log.Printf("log shipping error: %v", err)
} }
}
} }
func ensureDirs(cfg *config.Config) error { func ensureDirs(cfg *config.Config) error {
@@ -94,7 +106,9 @@ func ensureDirs(cfg *config.Config) error {
func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config) error { func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config) error {
send := func(payload []byte) error { send := func(payload []byte) error {
return retry(ctx, []time.Duration{250 * time.Millisecond, time.Second, 2 * time.Second}, func() error {
return apiClient.SendLogBatch(ctx, cfg.ServerID, payload) return apiClient.SendLogBatch(ctx, cfg.ServerID, payload)
})
} }
if err := logs.DrainSpool(cfg.LogSpoolDir(), send); err != nil { if err := logs.DrainSpool(cfg.LogSpoolDir(), send); err != nil {
return err return err
@@ -128,6 +142,22 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
return nil return nil
} }
func reportHost(ctx context.Context, apiClient *client.Client, cfg *config.Config) error {
info := host.Detect()
var pingPtr *int
if pingMs, err := apiClient.Ping(ctx); err == nil {
pingPtr = &pingMs
}
return retry(ctx, []time.Duration{250 * time.Millisecond, time.Second, 2 * time.Second}, func() error {
return apiClient.UpdateHost(ctx, cfg.ServerID, client.HeartbeatRequest{
Host: info.Hostname,
IPv4: info.IPv4,
IPv6: info.IPv6,
PingMs: pingPtr,
})
})
}
func pickServerURL(flagValue string) string { func pickServerURL(flagValue string) string {
if flagValue != "" { if flagValue != "" {
return flagValue return flagValue
@@ -159,11 +189,14 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
if err != nil { if err != nil {
return err return err
} }
hostname, _ := os.Hostname() info := host.Detect()
hostname := info.Hostname
resp, err := client.Enroll(context.Background(), cfg.ServerURL, client.EnrollRequest{ resp, err := client.Enroll(context.Background(), cfg.ServerURL, client.EnrollRequest{
Token: enrollToken, Token: enrollToken,
CSRPEM: csrPEM, CSRPEM: csrPEM,
Host: hostname, Host: hostname,
IPv4: info.IPv4,
IPv6: info.IPv6,
}) })
if err != nil { if err != nil {
return err return err
@@ -181,6 +214,28 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
return nil return nil
} }
func retry(ctx context.Context, delays []time.Duration, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
if attempt > 0 {
if !client.IsRetriable(lastErr) {
return lastErr
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delays[attempt-1]):
}
}
if err := fn(); err != nil {
lastErr = err
continue
}
return nil
}
return lastErr
}
func generateKey(path string) error { func generateKey(path string) error {
key, err := rsa.GenerateKey(rand.Reader, 2048) key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {

View File

@@ -1,7 +1,8 @@
{ {
"server_url": "https://keywarden.example.com", "server_url": "https://keywarden.dev.ntbx.io/api/v1",
"server_id": "", "server_id": "4",
"sync_interval_seconds": 30, "server_ca_path": "",
"sync_interval_seconds": 5,
"log_batch_size": 500, "log_batch_size": 500,
"state_dir": "/var/lib/keywarden-agent", "state_dir": "/var/lib/keywarden-agent",
"account_policy": { "account_policy": {

View File

@@ -0,0 +1,496 @@
package accounts
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"keywarden/agent/internal/config"
)
const (
stateFileName = "accounts.json"
maxUsernameLen = 32
passwdFilePath = "/etc/passwd"
groupFilePath = "/etc/group"
sshDirName = ".ssh"
authKeysName = "authorized_keys"
keywardenGroup = "keywarden"
userCAPath = "/etc/ssh/keywarden_user_ca.pub"
sshdConfigDropDir = "/etc/ssh/sshd_config.d"
sshdConfigDropIn = "/etc/ssh/sshd_config.d/keywarden.conf"
sshdConfigPath = "/etc/ssh/sshd_config"
)
type AccessUser struct {
UserID int
Username string
Email string
SystemUsername string
}
type ReportAccount struct {
UserID int `json:"user_id"`
SystemUser string `json:"system_username"`
Present bool `json:"present"`
}
type Result struct {
Applied int
Revoked int
Accounts []ReportAccount
}
type managedAccount struct {
UserID int `json:"user_id"`
SystemUser string `json:"system_username"`
}
type state struct {
Users map[string]managedAccount `json:"users"`
}
type passwdEntry struct {
UID int
GID int
Home string
}
func Sync(policy config.AccountPolicy, stateDir string, users []AccessUser) (Result, error) {
result := Result{}
statePath := filepath.Join(stateDir, stateFileName)
current, err := loadState(statePath)
if err != nil {
return result, err
}
desired := make(map[int]managedAccount, len(users))
for _, user := range users {
systemUser := user.SystemUsername
if strings.TrimSpace(systemUser) == "" {
systemUser = renderUsername(policy.UsernameTemplate, user.Username, user.UserID)
}
desired[user.UserID] = managedAccount{UserID: user.UserID, SystemUser: systemUser}
}
var syncErr error
if err := ensureGroup(keywardenGroup); err != nil && syncErr == nil {
syncErr = err
}
for _, account := range current.Users {
if _, ok := desired[account.UserID]; ok {
continue
}
if err := revokeUser(account.SystemUser, policy); err != nil && syncErr == nil {
syncErr = err
}
result.Revoked++
}
for userID, account := range desired {
present, err := ensureAccount(account.SystemUser, policy)
if err != nil && syncErr == nil {
syncErr = err
}
if present {
result.Applied++
}
result.Accounts = append(result.Accounts, ReportAccount{
UserID: userID,
SystemUser: account.SystemUser,
Present: present,
})
}
if err := saveState(statePath, desired); err != nil && syncErr == nil {
syncErr = err
}
return result, syncErr
}
func loadState(path string) (state, error) {
st := state{Users: map[string]managedAccount{}}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return st, nil
}
return st, fmt.Errorf("read state: %w", err)
}
if err := json.Unmarshal(data, &st); err != nil {
return st, fmt.Errorf("parse state: %w", err)
}
if st.Users == nil {
st.Users = map[string]managedAccount{}
}
return st, nil
}
func saveState(path string, desired map[int]managedAccount) error {
st := state{Users: map[string]managedAccount{}}
for id, account := range desired {
st.Users[strconv.Itoa(id)] = account
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("encode state: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create state dir: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write state: %w", err)
}
return nil
}
func renderUsername(template string, username string, userID int) string {
raw := strings.ReplaceAll(template, "{{username}}", username)
raw = strings.ReplaceAll(raw, "{{user_id}}", strconv.Itoa(userID))
clean := sanitizeUsername(raw)
if len(clean) > maxUsernameLen {
clean = clean[:maxUsernameLen]
}
if clean == "" {
clean = fmt.Sprintf("kw_%d", userID)
}
return clean
}
func sanitizeUsername(raw string) string {
raw = strings.ToLower(raw)
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
out := strings.Trim(b.String(), "-_")
if out == "" {
return ""
}
if strings.HasPrefix(out, "-") {
return "kw" + out
}
return out
}
func userExists(username string) (bool, error) {
cmd := exec.Command("id", "-u", username)
if err := cmd.Run(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
return false, nil
}
return false, err
}
return true, nil
}
func ensureAccount(username string, policy config.AccountPolicy) (bool, error) {
exists, err := userExists(username)
if err != nil {
return false, err
}
if !exists {
if err := createUser(username, policy); err != nil {
return false, err
}
}
if err := ensureGroupMembership(username, keywardenGroup); err != nil {
return true, err
}
if err := enforceCertificateOnly(username, policy); err != nil {
return true, err
}
if err := writeAuthorizedKeys(username, nil); err != nil {
return true, err
}
return true, nil
}
func createUser(username string, policy config.AccountPolicy) error {
args := []string{"-U", "-G", keywardenGroup}
if policy.CreateHome {
args = append(args, "-m")
} else {
args = append(args, "-M")
}
if policy.DefaultShell != "" {
args = append(args, "-s", policy.DefaultShell)
}
args = append(args, username)
cmd := exec.Command("useradd", args...)
if err := cmd.Run(); err != nil {
return fmt.Errorf("useradd %s: %w", username, err)
}
return nil
}
func enforceCertificateOnly(username string, policy config.AccountPolicy) error {
cmd := exec.Command("usermod", "-L", username)
if err := cmd.Run(); err != nil {
return fmt.Errorf("lock account %s: %w", username, err)
}
if policy.DefaultShell != "" {
shellCmd := exec.Command("usermod", "-s", policy.DefaultShell, username)
if err := shellCmd.Run(); err != nil {
return fmt.Errorf("set shell %s: %w", username, err)
}
}
expiryCmd := exec.Command("chage", "-E", "-1", username)
if err := expiryCmd.Run(); err != nil {
return fmt.Errorf("clear expiry %s: %w", username, err)
}
return nil
}
func revokeUser(username string, policy config.AccountPolicy) error {
exists, err := userExists(username)
if err != nil {
return err
}
if !exists {
return nil
}
var revokeErr error
if policy.LockOnRevoke {
if err := disableAccount(username); err != nil {
revokeErr = err
}
}
if err := writeAuthorizedKeys(username, nil); err != nil && revokeErr == nil {
revokeErr = err
}
return revokeErr
}
func disableAccount(username string) error {
cmd := exec.Command("usermod", "-L", username)
if err := cmd.Run(); err != nil {
return fmt.Errorf("lock account %s: %w", username, err)
}
expiryCmd := exec.Command("chage", "-E", "0", username)
if err := expiryCmd.Run(); err != nil {
return fmt.Errorf("expire account %s: %w", username, err)
}
return nil
}
func ensureGroup(name string) error {
exists, err := groupExists(name)
if err != nil {
return err
}
if exists {
return nil
}
cmd := exec.Command("groupadd", name)
if err := cmd.Run(); err != nil {
return fmt.Errorf("groupadd %s: %w", name, err)
}
return nil
}
func groupExists(name string) (bool, error) {
file, err := os.Open(groupFilePath)
if err != nil {
return false, fmt.Errorf("open group file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.SplitN(line, ":", 4)
if len(fields) < 1 {
continue
}
if fields[0] == name {
return true, nil
}
}
if err := scanner.Err(); err != nil {
return false, fmt.Errorf("scan group file: %w", err)
}
return false, nil
}
func ensureGroupMembership(username string, group string) error {
cmd := exec.Command("usermod", "-a", "-G", group, username)
if err := cmd.Run(); err != nil {
return fmt.Errorf("usermod add %s to %s: %w", username, group, err)
}
return nil
}
func EnsureCA(publicKey string) error {
key := strings.TrimSpace(publicKey)
if key == "" {
return errors.New("user CA public key required")
}
changed, err := writeCAKeyIfChanged(key)
if err != nil {
return err
}
configChanged, err := ensureSSHDConfig()
if err != nil {
return err
}
if changed || configChanged {
if err := reloadSSHD(); err != nil {
return err
}
}
return nil
}
func writeCAKeyIfChanged(key string) (bool, error) {
if data, err := os.ReadFile(userCAPath); err == nil {
if strings.TrimSpace(string(data)) == key {
return false, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("read user CA key: %w", err)
}
if err := os.WriteFile(userCAPath, []byte(key+"\n"), 0o644); err != nil {
return false, fmt.Errorf("write user CA key: %w", err)
}
return true, nil
}
func ensureSSHDConfig() (bool, error) {
content := fmt.Sprintf(
"TrustedUserCAKeys %s\nMatch Group %s\n AuthorizedKeysFile none\n PasswordAuthentication no\n ChallengeResponseAuthentication no\n",
userCAPath,
keywardenGroup,
)
if info, err := os.Stat(sshdConfigDropDir); err == nil && info.IsDir() {
if existing, err := os.ReadFile(sshdConfigDropIn); err == nil {
if string(existing) == content {
return false, nil
}
}
if err := os.WriteFile(sshdConfigDropIn, []byte(content), 0o644); err != nil {
return false, fmt.Errorf("write sshd drop-in: %w", err)
}
return true, nil
}
data, err := os.ReadFile(sshdConfigPath)
if err != nil {
return false, fmt.Errorf("read sshd config: %w", err)
}
if strings.Contains(string(data), "TrustedUserCAKeys "+userCAPath) {
return false, nil
}
updated := string(data)
if !strings.HasSuffix(updated, "\n") {
updated += "\n"
}
updated += "\n# Keywarden managed users\n" + content
if err := os.WriteFile(sshdConfigPath, []byte(updated), 0o644); err != nil {
return false, fmt.Errorf("write sshd config: %w", err)
}
return true, nil
}
func reloadSSHD() error {
if path, _ := exec.LookPath("systemctl"); path != "" {
if err := exec.Command("systemctl", "reload", "sshd").Run(); err == nil {
return nil
}
if err := exec.Command("systemctl", "reload", "ssh").Run(); err == nil {
return nil
}
}
if path, _ := exec.LookPath("service"); path != "" {
if err := exec.Command("service", "sshd", "reload").Run(); err == nil {
return nil
}
if err := exec.Command("service", "ssh", "reload").Run(); err == nil {
return nil
}
}
return errors.New("unable to reload sshd")
}
func writeAuthorizedKeys(username string, keys []string) error {
entry, err := lookupUser(username)
if err != nil {
return err
}
if entry.Home == "" {
return fmt.Errorf("missing home dir for %s", username)
}
sshDir := filepath.Join(entry.Home, sshDirName)
if err := os.MkdirAll(sshDir, 0o700); err != nil {
return fmt.Errorf("mkdir %s: %w", sshDir, err)
}
if err := os.Chmod(sshDir, 0o700); err != nil {
return fmt.Errorf("chmod %s: %w", sshDir, err)
}
if err := os.Chown(sshDir, entry.UID, entry.GID); err != nil {
return fmt.Errorf("chown %s: %w", sshDir, err)
}
authKeysPath := filepath.Join(sshDir, authKeysName)
payload := strings.TrimSpace(strings.Join(keys, "\n"))
if payload != "" {
payload += "\n"
}
if err := os.WriteFile(authKeysPath, []byte(payload), 0o600); err != nil {
return fmt.Errorf("write %s: %w", authKeysPath, err)
}
if err := os.Chown(authKeysPath, entry.UID, entry.GID); err != nil {
return fmt.Errorf("chown %s: %w", authKeysPath, err)
}
return nil
}
func lookupUser(username string) (passwdEntry, error) {
file, err := os.Open(passwdFilePath)
if err != nil {
return passwdEntry{}, fmt.Errorf("open passwd: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.SplitN(line, ":", 7)
if len(fields) < 7 {
continue
}
if fields[0] != username {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
return passwdEntry{}, fmt.Errorf("parse uid for %s: %w", username, err)
}
gid, err := strconv.Atoi(fields[3])
if err != nil {
return passwdEntry{}, fmt.Errorf("parse gid for %s: %w", username, err)
}
return passwdEntry{
UID: uid,
GID: gid,
Home: fields[5],
}, nil
}
if err := scanner.Err(); err != nil {
return passwdEntry{}, fmt.Errorf("scan passwd: %w", err)
}
return passwdEntry{}, fmt.Errorf("user %s not found", username)
}

View File

@@ -8,11 +8,14 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
"time" "time"
"keywarden/agent/internal/accounts"
"keywarden/agent/internal/config" "keywarden/agent/internal/config"
) )
@@ -21,6 +24,10 @@ const defaultTimeout = 15 * time.Second
type Client struct { type Client struct {
baseURL string baseURL string
http *http.Client http *http.Client
tlsCfg *tls.Config
scheme string
host string
addr string
} }
func New(cfg *config.Config) (*Client, error) { func New(cfg *config.Config) (*Client, error) {
@@ -32,13 +39,18 @@ func New(cfg *config.Config) (*Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("load client cert: %w", err) return nil, fmt.Errorf("load client cert: %w", err)
} }
caData, err := os.ReadFile(cfg.CACertPath()) caPool, err := x509.SystemCertPool()
if err != nil { if err != nil || caPool == nil {
return nil, fmt.Errorf("read ca cert: %w", err) caPool = x509.NewCertPool()
}
if cfg.ServerCAPath != "" {
caData, err := os.ReadFile(cfg.ServerCAPath)
if err != nil {
return nil, fmt.Errorf("read server ca cert: %w", err)
} }
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caData) { if !caPool.AppendCertsFromPEM(caData) {
return nil, errors.New("parse ca cert") return nil, errors.New("parse server ca cert")
}
} }
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
@@ -56,13 +68,44 @@ func New(cfg *config.Config) (*Client, error) {
Transport: transport, Transport: transport,
} }
return &Client{baseURL: baseURL, http: httpClient}, nil parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parse server url: %w", err)
}
if parsed.Host == "" {
return nil, errors.New("server url missing host")
}
scheme := parsed.Scheme
if scheme == "" {
scheme = "https"
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
if scheme == "http" {
port = "80"
} else {
port = "443"
}
}
addr := net.JoinHostPort(host, port)
return &Client{
baseURL: baseURL,
http: httpClient,
tlsCfg: tlsConfig,
scheme: scheme,
host: host,
addr: addr,
}, nil
} }
type EnrollRequest struct { type EnrollRequest struct {
Token string `json:"token"` Token string `json:"token"`
CSRPEM string `json:"csr_pem"` CSRPEM string `json:"csr_pem"`
Host string `json:"host"` Host string `json:"host"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
AgentID string `json:"agent_id,omitempty"` AgentID string `json:"agent_id,omitempty"`
} }
@@ -74,6 +117,38 @@ type EnrollResponse struct {
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
} }
type AccountKey struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
}
type AccountAccess struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
SystemUsername string `json:"system_username"`
Keys []AccountKey `json:"keys"`
}
type UserCAResponse struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
}
type AccountSyncEntry struct {
UserID int `json:"user_id"`
SystemUsername string `json:"system_username"`
Present bool `json:"present"`
}
type SyncReportRequest struct {
AppliedCount int `json:"applied_count"`
RevokedCount int `json:"revoked_count"`
Message string `json:"message,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Accounts []AccountSyncEntry `json:"accounts,omitempty"`
}
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 == "" {
@@ -107,10 +182,131 @@ func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollRe
return &out, nil return &out, nil
} }
func (c *Client) SyncAccounts(ctx context.Context, serverID string) error { func (c *Client) SyncAccounts(ctx context.Context, cfg *config.Config) error {
_ = ctx if cfg == nil {
_ = serverID return errors.New("config required for account sync")
// TODO: call API to fetch account policy + approved access list. }
ca, err := c.FetchUserCA(ctx, cfg.ServerID)
if err != nil {
return err
}
if err := accounts.EnsureCA(ca.PublicKey); err != nil {
return err
}
users, err := c.FetchAccountAccess(ctx, cfg.ServerID)
if err != nil {
return err
}
accessUsers := make([]accounts.AccessUser, 0, len(users))
for _, user := range users {
accessUsers = append(accessUsers, accounts.AccessUser{
UserID: user.UserID,
Username: user.Username,
Email: user.Email,
SystemUsername: user.SystemUsername,
})
}
result, syncErr := accounts.Sync(cfg.AccountPolicy, cfg.StateDir, accessUsers)
report := SyncReportRequest{
AppliedCount: result.Applied,
RevokedCount: result.Revoked,
Accounts: make([]AccountSyncEntry, 0, len(result.Accounts)),
}
for _, account := range result.Accounts {
report.Accounts = append(report.Accounts, AccountSyncEntry{
UserID: account.UserID,
SystemUsername: account.SystemUser,
Present: account.Present,
})
}
if syncErr != nil {
report.Message = syncErr.Error()
}
if err := c.SendSyncReport(ctx, cfg.ServerID, report); err != nil {
if syncErr != nil {
return fmt.Errorf("sync report failed: %w (sync error: %v)", err, syncErr)
}
return err
}
return syncErr
}
func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]AccountAccess, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
c.baseURL+"/agent/servers/"+serverID+"/accounts",
nil,
)
if err != nil {
return nil, fmt.Errorf("build account access request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch account access: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
}
var out []AccountAccess
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode account access: %w", err)
}
return out, nil
}
func (c *Client) FetchUserCA(ctx context.Context, serverID string) (*UserCAResponse, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
c.baseURL+"/agent/servers/"+serverID+"/ssh-ca",
nil,
)
if err != nil {
return nil, fmt.Errorf("build user ca request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch user ca: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
}
var out UserCAResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode user ca: %w", err)
}
if strings.TrimSpace(out.PublicKey) == "" {
return nil, errors.New("user ca missing public key")
}
return &out, nil
}
func (c *Client) SendSyncReport(ctx context.Context, serverID string, report SyncReportRequest) error {
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("encode sync report: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/agent/servers/"+serverID+"/sync-report",
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("build sync report: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("send sync report: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
}
return nil return nil
} }
@@ -126,7 +322,61 @@ func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []by
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
return fmt.Errorf("log batch failed: status %s", resp.Status) return &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
} }
return nil return nil
} }
type HeartbeatRequest struct {
Host string `json:"host,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
PingMs *int `json:"ping_ms,omitempty"`
}
func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody HeartbeatRequest) error {
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("encode host update: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/heartbeat", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build host update: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("send host update: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
}
return nil
}
func (c *Client) Ping(ctx context.Context) (int, error) {
if c.addr == "" {
return 0, errors.New("server address not configured")
}
start := time.Now()
dialer := &net.Dialer{Timeout: defaultTimeout}
if c.scheme == "http" {
conn, err := dialer.DialContext(ctx, "tcp", c.addr)
if err != nil {
return 0, err
}
_ = conn.Close()
return int(time.Since(start).Milliseconds()), nil
}
cfg := c.tlsCfg.Clone()
if cfg.ServerName == "" && c.host != "" {
cfg.ServerName = c.host
}
conn, err := tls.DialWithDialer(dialer, "tcp", c.addr, cfg)
if err != nil {
return 0, err
}
_ = conn.Close()
return int(time.Since(start).Milliseconds()), nil
}

View File

@@ -0,0 +1,36 @@
package client
import (
"context"
"errors"
"net"
)
type HTTPStatusError struct {
StatusCode int
Status string
}
func (e *HTTPStatusError) Error() string {
return "remote status " + e.Status
}
func IsRetriable(err error) bool {
if err == nil {
return false
}
var statusErr *HTTPStatusError
if errors.As(err, &statusErr) {
switch statusErr.StatusCode {
case 404, 408, 429, 500, 502, 503, 504:
return true
default:
return false
}
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
return errors.As(err, &netErr)
}

View File

@@ -29,6 +29,7 @@ type AccountPolicy struct {
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"`
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"`
StateDir string `json:"state_dir,omitempty"` StateDir string `json:"state_dir,omitempty"`
@@ -47,7 +48,7 @@ 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} cfg := &Config{ServerURL: serverURL, 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
@@ -61,6 +62,9 @@ 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)
} }
if cfg.ServerCAPath == "" {
cfg.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

View File

@@ -0,0 +1,57 @@
package host
import (
"net"
"os"
)
type Info struct {
Hostname string
IPv4 string
IPv6 string
}
func Detect() Info {
hostname, _ := os.Hostname()
info := Info{Hostname: hostname}
ifaces, err := net.Interfaces()
if err != nil {
return info
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
default:
continue
}
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if ip4 := ip.To4(); ip4 != nil {
if info.IPv4 == "" {
info.IPv4 = ip4.String()
}
continue
}
if ip.To16() != nil && info.IPv6 == "" {
info.IPv6 = ip.String()
}
}
if info.IPv4 != "" && info.IPv6 != "" {
break
}
}
return info
}

Binary file not shown.

View File

@@ -1,20 +1,108 @@
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
try:
from unfold.contrib.guardian.admin import GuardedModelAdmin
except ImportError: # Fallback for older Unfold builds without guardian admin shim.
from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass
from .models import AccessRequest from .models import AccessRequest
@admin.register(AccessRequest) @admin.register(AccessRequest)
class AccessRequestAdmin(GuardedModelAdmin): class AccessRequestAdmin(GuardedModelAdmin):
autocomplete_fields = ("requester", "server", "decided_by")
list_display = ( list_display = (
"id", "id",
"requester", "requester",
"server", "server",
"status", "status",
"request_shell",
"request_logs",
"request_users",
"requested_at", "requested_at",
"expires_at", "expires_at",
"decided_by", "decided_by",
"delete_link",
) )
list_filter = ("status", "server") list_filter = ("status", "server")
search_fields = ("requester__username", "requester__email", "server__display_name") search_fields = ("requester__username", "requester__email", "server__display_name")
ordering = ("-requested_at",) ordering = ("-requested_at",)
compressed_fields = True
actions_on_top = True
actions_on_bottom = True
def get_readonly_fields(self, request, obj=None):
readonly = ["requested_at"]
if obj:
readonly.extend(["decided_at", "decided_by"])
return readonly
def get_fieldsets(self, request, obj=None):
if obj is None:
return (
(
"Request",
{
"fields": (
"requester",
"server",
"status",
"reason",
"request_shell",
"request_logs",
"request_users",
"expires_at",
)
},
),
)
return (
(
"Request",
{
"fields": (
"requester",
"server",
"status",
"reason",
"request_shell",
"request_logs",
"request_users",
"expires_at",
)
},
),
(
"Decision",
{
"fields": (
"decided_at",
"decided_by",
)
},
),
)
def save_model(self, request, obj, form, change) -> None:
if obj.status in {
AccessRequest.Status.APPROVED,
AccessRequest.Status.DENIED,
AccessRequest.Status.REVOKED,
AccessRequest.Status.CANCELLED,
}:
if not obj.decided_at:
obj.decided_at = timezone.now()
if not obj.decided_by_id and request.user and request.user.is_authenticated:
obj.decided_by = request.user
super().save_model(request, obj, form, change)
def delete_link(self, obj: AccessRequest):
url = reverse("admin:access_accessrequest_delete", args=[obj.pk])
return format_html('<a class="text-red-600" href="{}">Delete</a>', url)
delete_link.short_description = "Delete"

View File

@@ -0,0 +1,37 @@
from django.db import migrations, models
def remove_delete_accessrequest_perm(apps, schema_editor):
Permission = apps.get_model("auth", "Permission")
ContentType = apps.get_model("contenttypes", "ContentType")
try:
content_type = ContentType.objects.get(app_label="access", model="accessrequest")
except ContentType.DoesNotExist:
return
Permission.objects.filter(content_type=content_type, codename="delete_accessrequest").delete()
class Migration(migrations.Migration):
dependencies = [
("access", "0001_initial"),
("auth", "__latest__"),
("contenttypes", "__latest__"),
]
operations = [
migrations.RunPython(remove_delete_accessrequest_perm, migrations.RunPython.noop),
migrations.AlterModelOptions(
name="accessrequest",
options={
"verbose_name": "Access request",
"verbose_name_plural": "Access requests",
"default_permissions": ("add", "view", "change"),
"indexes": [
models.Index(fields=["status", "requested_at"], name="acc_req_status_req_idx"),
models.Index(fields=["server", "status"], name="acc_req_server_status_idx"),
],
"ordering": ["-requested_at"],
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("access", "0002_remove_delete_permission"),
]
operations = [
migrations.AddField(
model_name="accessrequest",
name="request_shell",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="accessrequest",
name="request_logs",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="accessrequest",
name="request_users",
field=models.BooleanField(default=False),
),
]

View File

@@ -28,6 +28,9 @@ class AccessRequest(models.Model):
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
) )
reason = models.TextField(blank=True) reason = models.TextField(blank=True)
request_shell = models.BooleanField(default=False)
request_logs = models.BooleanField(default=False)
request_users = models.BooleanField(default=False)
requested_at = models.DateTimeField(default=timezone.now, editable=False) requested_at = models.DateTimeField(default=timezone.now, editable=False)
decided_at = models.DateTimeField(null=True, blank=True) decided_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
@@ -42,6 +45,7 @@ class AccessRequest(models.Model):
class Meta: class Meta:
verbose_name = "Access request" verbose_name = "Access request"
verbose_name_plural = "Access requests" verbose_name_plural = "Access requests"
default_permissions = ("add", "view", "change")
indexes = [ indexes = [
models.Index(fields=["status", "requested_at"], name="acc_req_status_req_idx"), models.Index(fields=["status", "requested_at"], name="acc_req_status_req_idx"),
models.Index(fields=["server", "status"], name="acc_req_server_status_idx"), models.Index(fields=["server", "status"], name="acc_req_server_status_idx"),

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from django.db.models import Q
from django.utils import timezone
from guardian.shortcuts import assign_perm, remove_perm
from .models import AccessRequest
def sync_server_view_perm(access_request: AccessRequest) -> None:
if not access_request or not access_request.requester_id or not access_request.server_id:
return
now = timezone.now()
has_valid_access = (
AccessRequest.objects.filter(
requester_id=access_request.requester_id,
server_id=access_request.server_id,
status=AccessRequest.Status.APPROVED,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.exists()
)
if has_valid_access:
assign_perm("servers.view_server", access_request.requester, access_request.server)
return
remove_perm("servers.view_server", access_request.requester, access_request.server)

View File

@@ -6,18 +6,17 @@ from guardian.shortcuts import assign_perm
from apps.core.rbac import assign_default_object_permissions from apps.core.rbac import assign_default_object_permissions
from .models import AccessRequest from .models import AccessRequest
from .permissions import sync_server_view_perm
@receiver(post_save, sender=AccessRequest) @receiver(post_save, sender=AccessRequest)
def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None: def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None:
if not created: if not created:
sync_server_view_perm(instance)
return return
if instance.requester_id: if instance.requester_id:
user = instance.requester user = instance.requester
for perm in ( for perm in ("access.view_accessrequest", "access.change_accessrequest"):
"access.view_accessrequest",
"access.change_accessrequest",
"access.delete_accessrequest",
):
assign_perm(perm, user, instance) assign_perm(perm, user, instance)
assign_default_object_permissions(instance) assign_default_object_permissions(instance)
sync_server_view_perm(instance)

27
app/apps/access/tasks.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from .models import AccessRequest
from .permissions import sync_server_view_perm
@shared_task
def expire_access_requests() -> int:
now = timezone.now()
expired_qs = AccessRequest.objects.select_related("server", "requester").filter(
status=AccessRequest.Status.APPROVED,
expires_at__isnull=False,
expires_at__lte=now,
)
count = 0
for access_request in expired_qs:
with transaction.atomic():
access_request.status = AccessRequest.Status.EXPIRED
access_request.decided_at = now
access_request.decided_by = None
access_request.save(update_fields=["status", "decided_at", "decided_by"])
sync_server_view_perm(access_request)
count += 1
return count

View File

@@ -1,3 +1,58 @@
from django import forms
from django.contrib import admin from django.contrib import admin
# from django.utils import timezone
# No custom models registered in accounts app. The legacy Account model has been removed. from unfold.admin import ModelAdmin
from .models import ErasureRequest
class ErasureRequestAdminForm(forms.ModelForm):
class Meta:
model = ErasureRequest
fields = "__all__"
def clean(self):
cleaned = super().clean()
status = cleaned.get("status")
decision_reason = (cleaned.get("decision_reason") or "").strip()
if status in {ErasureRequest.Status.DENIED, ErasureRequest.Status.PROCESSED} and not decision_reason:
raise forms.ValidationError("Decision reason is required for denied or processed requests.")
return cleaned
@admin.register(ErasureRequest)
class ErasureRequestAdmin(ModelAdmin):
form = ErasureRequestAdminForm
list_display = ("id", "user", "status", "requested_at", "decided_at", "processed_at")
list_filter = ("status", "requested_at", "processed_at")
search_fields = ("user__username", "user__email")
readonly_fields = ("requested_at", "decided_at", "processed_at", "decided_by", "processed_by")
fieldsets = (
(
"Request",
{
"fields": ("user", "reason", "status", "requested_at"),
},
),
(
"Decision",
{
"fields": ("decision_reason", "decided_by", "decided_at"),
},
),
(
"Processing",
{
"fields": ("processed_by", "processed_at"),
},
),
)
def save_model(self, request, obj, form, change) -> None:
if obj.status == ErasureRequest.Status.PROCESSED:
obj.process(request.user, decision_reason=obj.decision_reason)
return
if obj.status == ErasureRequest.Status.DENIED and not obj.decided_at:
obj.decided_at = timezone.now()
obj.decided_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,39 @@
from django import forms
class ErasureRequestForm(forms.Form):
reason = forms.CharField(
label="Reason for erasure request",
widget=forms.Textarea(
attrs={
"rows": 4,
"placeholder": "Explain why you are requesting data erasure.",
"class": "block w-full resize-y rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
}
),
min_length=10,
max_length=2000,
)
class SSHKeyForm(forms.Form):
name = forms.CharField(
label="Key Name",
max_length=128,
widget=forms.TextInput(
attrs={
"placeholder": "Device Name",
"class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
}
),
)
public_key = forms.CharField(
label="SSH Public Key",
widget=forms.Textarea(
attrs={
"rows": 4,
"placeholder": "ssh-ed25519 AAAaBBbBcCcc111122223333... user@host",
"class": "block w-full resize-y rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
}
),
)

View File

@@ -0,0 +1,75 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_unique_user_email_index"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ErasureRequest",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("reason", models.TextField()),
(
"status",
models.CharField(
choices=[("pending", "Pending"), ("denied", "Denied"), ("processed", "Processed")],
db_index=True,
default="pending",
max_length=16,
),
),
("requested_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("decided_at", models.DateTimeField(blank=True, null=True)),
("decision_reason", models.TextField(blank=True)),
("processed_at", models.DateTimeField(blank=True, null=True)),
(
"decided_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="erasure_decisions",
to=settings.AUTH_USER_MODEL,
),
),
(
"processed_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="erasure_processes",
to=settings.AUTH_USER_MODEL,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="erasure_requests",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Erasure request",
"verbose_name_plural": "Erasure requests",
"ordering": ["-requested_at"],
},
),
migrations.AddIndex(
model_name="erasurerequest",
index=models.Index(fields=["status", "requested_at"], name="accounts_erasure_status_idx"),
),
migrations.AddIndex(
model_name="erasurerequest",
index=models.Index(fields=["user", "status"], name="accounts_er_user_status_idx"),
),
]

View File

@@ -1,3 +1,126 @@
from django.db import models from __future__ import annotations
#
# Legacy Account model has been removed. This app now contains URLs/views only. import uuid
from django.conf import settings
from django.db import models, transaction
from django.utils import timezone
class ErasureRequest(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
DENIED = "denied", "Denied"
PROCESSED = "processed", "Processed"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="erasure_requests",
)
reason = models.TextField()
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True)
requested_at = models.DateTimeField(default=timezone.now, editable=False)
decided_at = models.DateTimeField(null=True, blank=True)
decided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="erasure_decisions",
)
decision_reason = models.TextField(blank=True)
processed_at = models.DateTimeField(null=True, blank=True)
processed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="erasure_processes",
)
class Meta:
verbose_name = "Erasure request"
verbose_name_plural = "Erasure requests"
ordering = ["-requested_at"]
indexes = [
models.Index(fields=["status", "requested_at"], name="accounts_erasure_status_idx"),
models.Index(fields=["user", "status"], name="accounts_er_user_status_idx"),
]
def __str__(self) -> str:
return f"Erasure request #{self.id} ({self.user_id})"
def process(self, admin_user, decision_reason: str = "") -> None:
if self.status == self.Status.PROCESSED:
return
now = timezone.now()
with transaction.atomic():
self._anonymize_user(admin_user, now)
self.status = self.Status.PROCESSED
self.decided_at = now
self.decided_by = admin_user
self.decision_reason = (decision_reason or "").strip()
self.processed_at = now
self.processed_by = admin_user
self.save(
update_fields=[
"status",
"decided_at",
"decided_by",
"decision_reason",
"processed_at",
"processed_by",
]
)
def _anonymize_user(self, admin_user, now) -> None:
from guardian.models import UserObjectPermission
from apps.access.models import AccessRequest
from apps.keys.models import SSHCertificate, SSHKey
user = self.user
token = uuid.uuid4().hex
anonymous_username = f"erased-{token}"
anonymous_email = f"{anonymous_username}@erased.local"
user.username = anonymous_username
user.email = anonymous_email
user.first_name = ""
user.last_name = ""
user.is_active = False
user.is_staff = False
user.is_superuser = False
user.last_login = None
user.set_unusable_password()
user.save(
update_fields=[
"username",
"email",
"first_name",
"last_name",
"is_active",
"is_staff",
"is_superuser",
"last_login",
"password",
]
)
user.groups.clear()
user.user_permissions.clear()
UserObjectPermission.objects.filter(user=user).delete()
SSHKey.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now)
SSHCertificate.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now)
AccessRequest.objects.filter(requester=user).update(reason="[redacted]")
AccessRequest.objects.filter(
requester=user,
status__in=[AccessRequest.Status.PENDING, AccessRequest.Status.APPROVED],
).update(
status=AccessRequest.Status.REVOKED,
decided_at=now,
decided_by=admin_user,
expires_at=now,
)

View File

@@ -4,35 +4,55 @@
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8"> <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Sign in</h1> <div class="space-y-2">
<form method="post" class="space-y-4"> <h1 class="text-2xl font-semibold tracking-tight text-gray-900">Welcome back</h1>
{% csrf_token %} <p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p>
<input type="hidden" name="next" value="{% url 'accounts:profile' %}">
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700">Username</label>
<input type="text" name="username" autocomplete="username" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600">
</div> </div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-gray-700">Password</label> <form method="post" class="mt-6 space-y-5">
<input type="password" name="password" autocomplete="current-password" required class="block w-full rounded-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600"> {% csrf_token %}
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Username</label>
<input
type="text"
name="username"
autocomplete="username"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
<input
type="password"
name="password"
autocomplete="current-password"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div> </div>
{% if form.errors %} {% if form.errors %}
<p class="text-sm text-red-600">Please check your username and password.</p> <div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
<span class="font-medium">Sign-in failed.</span>
<span>Please check your username and password.</span>
</div>
{% endif %} {% endif %}
<div class="pt-2"> <button
<button type="submit" class="inline-flex w-full items-center justify-center rounded-md bg-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600"> type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Sign in Sign in
</button> </button>
</div>
</form> </form>
<div class="mt-6 border-t border-gray-200 pt-6"> <div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Or, if configured, use Or, if configured, use
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">OIDC login</a>. <a href="/oidc/authenticate/" class="font-medium text-blue-700 hover:underline">OIDC login</a>.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -3,47 +3,281 @@
{% block title %}Profile • Keywarden{% endblock %} {% block title %}Profile • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="space-y-6">
<div> <div class="grid gap-6 lg:grid-cols-3">
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
<h1 class="mb-6 text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1> <div class="space-y-2">
<dl class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> <h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
<div> <p class="text-sm text-gray-500">Account details and contact information.</p>
<dt class="text-sm font-medium text-gray-500">Username</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd>
</div> </div>
<div> <dl class="mt-6 grid grid-cols-1 gap-4 text-sm text-gray-600 sm:grid-cols-2">
<dt class="text-sm font-medium text-gray-500">Email</dt> <div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dd class="mt-1 text-sm text-gray-900">{{ user.email }}</dd> <dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Username</dt>
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.username }}</dd>
</div> </div>
<div> <div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-sm font-medium text-gray-500">First name</dt> <dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Email</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.first_name|default:"—" }}</dd> <dd class="mt-2 text-sm font-medium text-gray-900">{{ user.email }}</dd>
</div> </div>
<div> <div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-sm font-medium text-gray-500">Last name</dt> <dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">First name</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user.last_name|default:"—" }}</dd> <dd class="mt-2 text-sm font-medium text-gray-900">{{ user.first_name|default:"—" }}</dd>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4">
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500">Last name</dt>
<dd class="mt-2 text-sm font-medium text-gray-900">{{ user.last_name|default:"—" }}</dd>
</div> </div>
</dl> </dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
<p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
</div> </div>
</div> <div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
<div>
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
<h2 class="mb-4 text-base font-semibold tracking-tight text-gray-900">Single Sign-On</h2>
{% if auth_mode == "hybrid" %} {% if auth_mode == "hybrid" %}
<div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600">
Optional: Link your account with your identity provider for single sign-on. Optional: Link your account with your identity provider for single sign-on.
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">Link with SSO</a> <a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a>
</p>
</div>
{% elif auth_mode == "oidc" %} {% elif auth_mode == "oidc" %}
<p class="text-sm text-gray-600">OIDC is required. Sign-in is managed by your identity provider.</p> OIDC is required. Sign-in is managed by your identity provider.
{% else %} {% else %}
<p class="text-sm text-gray-600">OIDC is disabled. You are using native authentication.</p> OIDC is disabled. You are using native authentication.
{% endif %} {% endif %}
</div> </div>
</section>
</div> </div>
</div>
{% endblock %}
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
<p class="mt-1 text-sm text-gray-500">
Upload your SSH public key to receive a signed certificate for server access.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Certificates</span>
</div>
{% if can_add_key %}
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Key name
</label>
{{ key_form.name }}
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div class="lg:col-span-2">
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
SSH public key
</label>
{{ key_form.public_key }}
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Upload key
</button>
</div>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
<table class="w-full text-left text-sm text-gray-500">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th scope="col" class="px-6 py-3">Key</th>
<th scope="col" class="px-6 py-3">Fingerprint</th>
<th scope="col" class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for key in ssh_keys %}
<tr class="border-t bg-white">
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
{{ key.name }}
</th>
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap items-center justify-end gap-2">
{% if key.is_active %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
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 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Data erasure request</h2>
<p class="mt-1 text-sm text-gray-500">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</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">GDPR</span>
</div>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %}
</div>
{% endif %}
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-6 grid gap-4">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Reason for request
</label>
{{ erasure_form.reason }}
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %}
</div>
{% if erasure_form.non_field_errors %}
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit erasure request
</button>
</div>
</form>
{% endif %}
</section>
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.alert("Certificate regenerated.");
})
.catch(function (err) {
window.alert(err.message);
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
})();
</script>
{% endblock %}

View File

@@ -1,16 +1,72 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect
from django.contrib.auth import views as auth_views
from django.contrib.auth import logout from django.contrib.auth import logout
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.shortcuts import redirect, render
from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def profile(request): def profile(request):
erasure_request = (
ErasureRequest.objects.filter(user=request.user).order_by("-requested_at").first()
)
can_add_key = request.user.has_perm("keys.add_sshkey")
if request.method == "POST":
form_type = request.POST.get("form_type")
if form_type == "ssh_key":
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm(request.POST)
if key_form.is_valid():
if not can_add_key:
key_form.add_error(None, "You do not have permission to add SSH keys.")
else:
name = key_form.cleaned_data["name"].strip()
public_key = key_form.cleaned_data["public_key"].strip()
key = SSHKey(user=request.user, name=name)
try:
key.set_public_key(public_key)
key.save()
issue_certificate_for_key(key, created_by=request.user)
return redirect("accounts:profile")
except ValidationError as exc:
key_form.add_error("public_key", str(exc))
except IntegrityError:
key_form.add_error("public_key", "Key already exists.")
except Exception:
key_form.add_error(None, "Certificate issuance failed.")
else:
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm(request.POST)
if erasure_form.is_valid():
if erasure_request and erasure_request.status == ErasureRequest.Status.PENDING:
erasure_form.add_error(None, "You already have a pending erasure request.")
else:
ErasureRequest.objects.create(
user=request.user,
reason=erasure_form.cleaned_data["reason"].strip(),
)
return redirect("accounts:profile")
else:
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
context = { context = {
"user": request.user, "user": request.user,
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"), "auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
"erasure_request": erasure_request,
"erasure_form": erasure_form,
"key_form": key_form,
"ssh_keys": ssh_keys,
"can_add_key": can_add_key,
} }
return render(request, "accounts/profile.html", context) return render(request, "accounts/profile.html", context)
@@ -26,4 +82,3 @@ def login_view(request):
def logout_view(request): def logout_view(request):
logout(request) logout(request)
return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/")) return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/"))

View File

@@ -1,17 +1,140 @@
import json
from django import forms
from django.contrib import admin from django.contrib import admin
from unfold.admin import ModelAdmin from unfold.admin import ModelAdmin
from unfold.decorators import action # type: ignore
from .matching import list_api_endpoint_suggestions, list_websocket_endpoint_suggestions
from .models import AuditEventType, AuditLog from .models import AuditEventType, AuditLog
class AuditEventTypeAdminForm(forms.ModelForm):
endpoints_text = forms.CharField(
required=False,
widget=forms.Textarea(
attrs={
"rows": 8,
"placeholder": "/api/v1/servers/\nGET /api/v1/servers/<int:server_id>/\n/ws/servers/*/shell/",
}
),
help_text=(
"One endpoint pattern per line. Supports '*' wildcards and optional METHOD prefixes "
"like 'GET /api/v1/servers/*'."
),
label="Endpoint patterns",
)
ip_whitelist_text = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "10.0.0.1\n192.168.1.0/24"}),
help_text="One IP address or CIDR range per line.",
label="IP whitelist entries",
)
ip_blacklist_text = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "203.0.113.10\n198.51.100.0/24"}),
help_text="One IP address or CIDR range per line.",
label="IP blacklist entries",
)
class Meta:
model = AuditEventType
fields = (
"key",
"title",
"description",
"kind",
"default_severity",
"endpoints_text",
"ip_whitelist_enabled",
"ip_whitelist_text",
"ip_blacklist_enabled",
"ip_blacklist_text",
)
class Media:
js = ("audit/eventtype_form.js",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
instance = kwargs.get("instance") or getattr(self, "instance", None)
if instance and instance.pk:
self.fields["endpoints_text"].initial = "\n".join(instance.endpoints or [])
self.fields["ip_whitelist_text"].initial = "\n".join(instance.ip_whitelist or [])
self.fields["ip_blacklist_text"].initial = "\n".join(instance.ip_blacklist or [])
self.fields["endpoints_text"].widget.attrs["data-api-suggestions"] = json.dumps(
list_api_endpoint_suggestions()
)
self.fields["endpoints_text"].widget.attrs["data-ws-suggestions"] = json.dumps(
list_websocket_endpoint_suggestions()
)
def _lines_to_list(self, value: str) -> list[str]:
results: list[str] = []
for line in (value or "").splitlines():
candidate = line.strip()
if candidate:
results.append(candidate)
return results
def clean_endpoints_text(self) -> str:
value = self.cleaned_data.get("endpoints_text", "")
# Normalize whitespace but keep the raw text for display.
lines = self._lines_to_list(value)
return "\n".join(lines)
def save(self, commit: bool = True):
instance: AuditEventType = super().save(commit=False)
endpoints_text = self.cleaned_data.get("endpoints_text", "")
whitelist_text = self.cleaned_data.get("ip_whitelist_text", "")
blacklist_text = self.cleaned_data.get("ip_blacklist_text", "")
instance.endpoints = self._lines_to_list(endpoints_text)
instance.ip_whitelist = self._lines_to_list(whitelist_text)
instance.ip_blacklist = self._lines_to_list(blacklist_text)
if commit:
instance.save()
return instance
@admin.register(AuditEventType) @admin.register(AuditEventType)
class AuditEventTypeAdmin(ModelAdmin): class AuditEventTypeAdmin(ModelAdmin):
list_display = ("key", "title", "default_severity", "created_at") form = AuditEventTypeAdminForm
search_fields = ("key", "title", "description") list_display = ("key", "title", "kind", "default_severity", "created_at")
list_filter = ("default_severity",) search_fields = ("key", "title", "description", "endpoints")
list_filter = ("kind", "default_severity", "ip_whitelist_enabled", "ip_blacklist_enabled")
ordering = ("key",) ordering = ("key",)
compressed_fields = True compressed_fields = True
fieldsets = (
(
"Event Type",
{
"fields": (
"key",
"title",
"description",
"kind",
"default_severity",
)
},
),
(
"Endpoints",
{
"fields": ("endpoints_text",),
"description": "Only matching endpoints will create audit events.",
},
),
(
"IP Controls",
{
"fields": (
"ip_whitelist_enabled",
"ip_whitelist_text",
"ip_blacklist_enabled",
"ip_blacklist_text",
),
},
),
)
@admin.register(AuditLog) @admin.register(AuditLog)
@@ -87,5 +210,3 @@ class AuditLogAdmin(ModelAdmin):
{"fields": ("metadata",)}, {"fields": ("metadata",)},
), ),
) )

View File

@@ -1,4 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_delete, post_save
class AuditConfig(AppConfig): class AuditConfig(AppConfig):
@@ -10,6 +11,10 @@ class AuditConfig(AppConfig):
def ready(self) -> None: def ready(self) -> None:
# Import signal handlers # Import signal handlers
from . import signals # noqa: F401 from . import signals # noqa: F401
from .matching import clear_event_type_cache
from .models import AuditEventType
post_save.connect(clear_event_type_cache, sender=AuditEventType)
post_delete.connect(clear_event_type_cache, sender=AuditEventType)
return super().ready() return super().ready()

231
app/apps/audit/matching.py Normal file
View File

@@ -0,0 +1,231 @@
from __future__ import annotations
import fnmatch
import ipaddress
import re
import time
from dataclasses import dataclass
from typing import Iterable
from django.urls import URLPattern, URLResolver, get_resolver
from .models import AuditEventType
_CACHE_TTL_SECONDS = 15.0
_METHOD_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(.+)$", re.IGNORECASE)
_REGEX_GROUP_RE = re.compile(r"\(\?P<(?P<name>\w+)>[^)]+\)")
_CONVERTER_RE = re.compile(r"<(?:(?P<converter>[^:>]+):)?(?P<name>[^>]+)>")
@dataclass(frozen=True)
class ParsedEndpointPattern:
method: str | None
pattern: str
def _normalize_path(value: str) -> str:
candidate = (value or "").strip()
if not candidate:
return ""
if "?" in candidate:
candidate = candidate.split("?", 1)[0]
if not candidate.startswith("/"):
candidate = f"/{candidate}"
# Collapse duplicate slashes without being clever.
while "//" in candidate:
candidate = candidate.replace("//", "/")
return candidate
def _strip_regex_anchors(value: str) -> str:
candidate = value.strip()
if candidate.startswith("^"):
candidate = candidate[1:]
if candidate.endswith("$"):
candidate = candidate[:-1]
return candidate
def _placeholder_to_wildcard(value: str) -> str:
candidate = _strip_regex_anchors(value)
candidate = _REGEX_GROUP_RE.sub("*", candidate)
candidate = _CONVERTER_RE.sub("*", candidate)
return candidate
def parse_endpoint_pattern(raw_pattern: str) -> ParsedEndpointPattern | None:
# Parse admin-provided patterns like:
# - "/api/v1/servers/*"
# - "GET /api/v1/servers/<int:server_id>/"
# We normalize both Django-style placeholders and regex routes into
# fnmatch-friendly wildcard patterns.
if not raw_pattern:
return None
raw = raw_pattern.strip()
if not raw:
return None
method: str | None = None
endpoint = raw
match = _METHOD_RE.match(raw)
if match:
method = match.group(1).upper()
endpoint = match.group(2)
endpoint = _normalize_path(_placeholder_to_wildcard(endpoint))
if not endpoint:
return None
return ParsedEndpointPattern(method=method, pattern=endpoint)
def _endpoint_matches_pattern(pattern: ParsedEndpointPattern, method: str, route: str, path: str) -> bool:
if pattern.method and pattern.method != method.upper():
return False
route_norm = _normalize_path(route)
path_norm = _normalize_path(path)
return fnmatch.fnmatch(route_norm, pattern.pattern) or fnmatch.fnmatch(path_norm, pattern.pattern)
def _parse_ip_entry(
entry: str,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Network | ipaddress.IPv6Network | None:
raw = (entry or "").strip()
if not raw:
return None
try:
if "/" in raw:
return ipaddress.ip_network(raw, strict=False)
return ipaddress.ip_address(raw)
except ValueError:
return None
def _ip_in_entries(ip: str, entries: Iterable[str]) -> bool:
try:
candidate_ip = ipaddress.ip_address(ip)
except ValueError:
return False
for entry in entries:
parsed = _parse_ip_entry(entry)
if parsed is None:
continue
if isinstance(parsed, (ipaddress.IPv4Network, ipaddress.IPv6Network)):
if candidate_ip in parsed:
return True
elif candidate_ip == parsed:
return True
return False
def ip_allowed_for_event(event_type: AuditEventType, ip: str | None) -> bool:
# Apply whitelist first (default deny when enabled), then blacklist
# (explicit deny). If the IP cannot be determined, we only allow it
# when no whitelist is enforced.
if not ip:
# If we cannot determine the IP, allow by default unless a whitelist is enforced.
return not event_type.ip_whitelist_enabled
if event_type.ip_whitelist_enabled and not _ip_in_entries(ip, event_type.ip_whitelist or []):
return False
if event_type.ip_blacklist_enabled and _ip_in_entries(ip, event_type.ip_blacklist or []):
return False
return True
def endpoint_matches_event(event_type: AuditEventType, method: str, route: str, path: str) -> bool:
# Event types are opt-in: an empty endpoint list never matches.
# We allow either the resolved Django route or the raw path to match
# so patterns can be authored using whichever is more stable.
patterns = event_type.endpoints or []
if not patterns:
return False
for raw_pattern in patterns:
parsed = parse_endpoint_pattern(str(raw_pattern))
if parsed and _endpoint_matches_pattern(parsed, method, route, path):
return True
return False
_EVENT_TYPE_CACHE: dict[str, tuple[float, list[AuditEventType]]] = {}
def clear_event_type_cache(*_args, **_kwargs) -> None:
_EVENT_TYPE_CACHE.clear()
def get_event_types_for_kind(kind: str) -> list[AuditEventType]:
# Cache event-type catalogs briefly to avoid repeated DB hits on
# high-volume request paths. The cache is cleared on save/delete.
now = time.monotonic()
cached = _EVENT_TYPE_CACHE.get(kind)
if cached and (now - cached[0]) < _CACHE_TTL_SECONDS:
return cached[1]
event_types = list(AuditEventType.objects.filter(kind=kind).order_by("key"))
_EVENT_TYPE_CACHE[kind] = (now, event_types)
return event_types
def find_matching_event_type(kind: str, method: str, route: str, path: str, ip: str | None) -> AuditEventType | None:
# Deterministic first-match semantics: the ordered catalog defines
# precedence when multiple event types could match.
for event_type in get_event_types_for_kind(kind):
if not endpoint_matches_event(event_type, method=method, route=route, path=path):
continue
if not ip_allowed_for_event(event_type, ip):
continue
return event_type
return None
def _join_paths(prefix: str, segment: str) -> str:
if not prefix:
return segment
if not segment:
return prefix
return f"{prefix.rstrip('/')}/{segment.lstrip('/')}"
def _walk_urlpatterns(patterns: Iterable[URLPattern | URLResolver], prefix: str = "") -> list[str]:
# Flatten the resolver tree into full route strings so the admin
# UI can offer endpoint suggestions without hardcoding routes.
results: list[str] = []
for pattern in patterns:
segment = str(pattern.pattern)
combined = _join_paths(prefix, segment)
if isinstance(pattern, URLResolver):
results.extend(_walk_urlpatterns(pattern.url_patterns, combined))
else:
results.append(combined)
return results
def _normalize_suggestion(value: str) -> str:
candidate = _strip_regex_anchors(value)
candidate = candidate.replace("\\", "")
candidate = _REGEX_GROUP_RE.sub(lambda m: f"<{m.group('name')}>", candidate)
candidate = _normalize_path(candidate)
return candidate
def list_api_endpoint_suggestions() -> list[str]:
# Introspect the URL resolver and keep only API routes. Suggestions
# are normalized to human-editable patterns (e.g., "<server_id>").
resolver = get_resolver()
raw_patterns = _walk_urlpatterns(resolver.url_patterns)
suggestions: set[str] = set()
for pattern in raw_patterns:
if not pattern:
continue
normalized = _normalize_suggestion(pattern)
if normalized.startswith("/api"):
suggestions.add(normalized)
return sorted(s for s in suggestions if s)
def list_websocket_endpoint_suggestions() -> list[str]:
# WebSocket routes are maintained separately by Channels, so we
# import them directly from the ASGI routing module.
try:
from keywarden.routing import websocket_urlpatterns
except Exception:
return []
raw_patterns = [str(p.pattern) for p in websocket_urlpatterns]
suggestions = {_normalize_suggestion(p) for p in raw_patterns}
return sorted(s for s in suggestions if s)

View File

@@ -1,15 +1,13 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import time import time
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify
from .matching import find_matching_event_type
from .models import AuditEventType, AuditLog from .models import AuditEventType, AuditLog
from .utils import get_client_ip, get_request_id from .utils import get_client_ip, get_request_id
_EVENT_CACHE: dict[str, AuditEventType] = {}
_SKIP_PREFIXES = ("/api/v1/audit", "/api/v1/user") _SKIP_PREFIXES = ("/api/v1/audit", "/api/v1/user")
_SKIP_SUFFIXES = ("/health", "/health/") _SKIP_SUFFIXES = ("/health", "/health/")
@@ -18,6 +16,8 @@ def _is_api_request(path: str) -> bool:
def _should_log_request(path: str) -> bool: def _should_log_request(path: str) -> bool:
# Only audit API traffic and skip endpoints that would recursively
# generate noisy audit events (audit endpoints, health checks, etc.).
if not _is_api_request(path): if not _is_api_request(path):
return False return False
if path in _SKIP_PREFIXES: if path in _SKIP_PREFIXES:
@@ -37,46 +37,12 @@ def _resolve_route(request, fallback: str) -> str:
return fallback return fallback
def _event_key_for(method: str, route: str) -> str:
base = f"api_{method.lower()}_{route}"
slug = slugify(base)
if not slug:
return "api_request"
if len(slug) <= 64:
return slug
digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:8]
prefix_len = 64 - len(digest) - 1
return f"{slug[:prefix_len]}-{digest}"
def _event_title_for(method: str, route: str) -> str:
title = f"API {method.upper()} {route}"
if len(title) <= 128:
return title
return f"{title[:125]}..."
def _get_endpoint_event(method: str, route: str) -> AuditEventType:
key = _event_key_for(method, route)
cached = _EVENT_CACHE.get(key)
if cached is not None:
return cached
event, _ = AuditEventType.objects.get_or_create(
key=key,
defaults={
"title": _event_title_for(method, route),
"default_severity": AuditEventType.Severity.INFO,
},
)
_EVENT_CACHE[key] = event
return event
class ApiAuditLogMiddleware: class ApiAuditLogMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
# Fast-exit for non-audited paths before taking timing measurements.
path = request.path_info or request.path path = request.path_info or request.path
if not _should_log_request(path): if not _should_log_request(path):
return self.get_response(request) return self.get_response(request)
@@ -96,8 +62,21 @@ class ApiAuditLogMiddleware:
def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None: def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None:
try: try:
route = _resolve_route(request, path) route = _resolve_route(request, path)
client_ip = get_client_ip(request)
# Audit events are explicit: if no configured event type matches,
# we do not create either an event type or a log entry.
event_type = find_matching_event_type(
kind=AuditEventType.Kind.API,
method=request.method,
route=route,
path=path,
ip=client_ip,
)
if event_type is None:
return
user = getattr(request, "user", None) user = getattr(request, "user", None)
actor = user if getattr(user, "is_authenticated", False) else None actor = user if getattr(user, "is_authenticated", False) else None
# Store normalized request context for filtering and forensics.
metadata = { metadata = {
"method": request.method, "method": request.method,
"path": path, "path": path,
@@ -111,11 +90,11 @@ class ApiAuditLogMiddleware:
AuditLog.objects.create( AuditLog.objects.create(
created_at=timezone.now(), created_at=timezone.now(),
actor=actor, actor=actor,
event_type=_get_endpoint_event(request.method, route), event_type=event_type,
message=f"API request {request.method} {route} -> {status_code}", message=f"API request {request.method} {route} -> {status_code}",
severity=AuditEventType.Severity.INFO, severity=event_type.default_severity,
source=AuditLog.Source.API, source=AuditLog.Source.API,
ip_address=get_client_ip(request), ip_address=client_ip,
user_agent=request.META.get("HTTP_USER_AGENT", ""), user_agent=request.META.get("HTTP_USER_AGENT", ""),
request_id=get_request_id(request), request_id=get_request_id(request),
metadata=metadata, metadata=metadata,

View File

@@ -0,0 +1,69 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("audit", "0002_alter_auditlog_event_type"),
]
operations = [
migrations.AddField(
model_name="auditeventtype",
name="kind",
field=models.CharField(
choices=[("api", "API"), ("websocket", "WebSocket")],
db_index=True,
default="api",
help_text="Whether this event type applies to API or WebSocket traffic.",
max_length=16,
),
),
migrations.AddField(
model_name="auditeventtype",
name="endpoints",
field=models.JSONField(
blank=True,
default=list,
help_text=(
"List of endpoint patterns that should generate this event type. "
"Use one pattern per line in the admin form. Supports '*' wildcards "
"and optional METHOD prefixes like 'GET /api/v1/servers/*'."
),
),
),
migrations.AddField(
model_name="auditeventtype",
name="ip_whitelist_enabled",
field=models.BooleanField(
default=False,
help_text="If enabled, only IPs in the whitelist will generate this event type.",
),
),
migrations.AddField(
model_name="auditeventtype",
name="ip_whitelist",
field=models.JSONField(
blank=True,
default=list,
help_text="List of allowed IP addresses or CIDR ranges. One per line in the admin form.",
),
),
migrations.AddField(
model_name="auditeventtype",
name="ip_blacklist_enabled",
field=models.BooleanField(
default=False,
help_text="If enabled, IPs in the blacklist will be blocked for this event type.",
),
),
migrations.AddField(
model_name="auditeventtype",
name="ip_blacklist",
field=models.JSONField(
blank=True,
default=list,
help_text="List of denied IP addresses or CIDR ranges. One per line in the admin form.",
),
),
]

View File

@@ -13,6 +13,10 @@ class AuditEventType(models.Model):
Useful for consistent naming, severity, and descriptions. Useful for consistent naming, severity, and descriptions.
""" """
class Kind(models.TextChoices):
API = "api", "API"
WEBSOCKET = "websocket", "WebSocket"
class Severity(models.TextChoices): class Severity(models.TextChoices):
INFO = "info", "Info" INFO = "info", "Info"
WARNING = "warning", "Warning" WARNING = "warning", "Warning"
@@ -22,9 +26,43 @@ class AuditEventType(models.Model):
key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login") key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login")
title = models.CharField(max_length=128, help_text="Human-readable title") title = models.CharField(max_length=128, help_text="Human-readable title")
description = models.TextField(blank=True) description = models.TextField(blank=True)
kind = models.CharField(
max_length=16,
choices=Kind.choices,
default=Kind.API,
db_index=True,
help_text="Whether this event type applies to API or WebSocket traffic.",
)
default_severity = models.CharField( default_severity = models.CharField(
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
) )
endpoints = models.JSONField(
default=list,
blank=True,
help_text=(
"List of endpoint patterns that should generate this event type. "
"Use one pattern per line in the admin form. Supports '*' wildcards "
"and optional METHOD prefixes like 'GET /api/v1/servers/*'."
),
)
ip_whitelist_enabled = models.BooleanField(
default=False,
help_text="If enabled, only IPs in the whitelist will generate this event type.",
)
ip_whitelist = models.JSONField(
default=list,
blank=True,
help_text="List of allowed IP addresses or CIDR ranges. One per line in the admin form.",
)
ip_blacklist_enabled = models.BooleanField(
default=False,
help_text="If enabled, IPs in the blacklist will be blocked for this event type.",
)
ip_blacklist = models.JSONField(
default=list,
blank=True,
help_text="List of denied IP addresses or CIDR ranges. One per line in the admin form.",
)
created_at = models.DateTimeField(default=timezone.now, editable=False) created_at = models.DateTimeField(default=timezone.now, editable=False)
class Meta: class Meta:
@@ -33,7 +71,7 @@ class AuditEventType(models.Model):
ordering = ["key"] ordering = ["key"]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.key} ({self.default_severity})" return f"{self.key} [{self.kind}] ({self.default_severity})"
class AuditLog(models.Model): class AuditLog(models.Model):

View File

@@ -11,17 +11,18 @@ from .utils import get_client_ip
User = get_user_model() User = get_user_model()
def _get_or_create_event(key: str, title: str, severity: str = AuditEventType.Severity.INFO) -> AuditEventType: def _get_event(key: str) -> AuditEventType | None:
event, _ = AuditEventType.objects.get_or_create( try:
key=key, return AuditEventType.objects.get(key=key)
defaults={"title": title, "default_severity": severity}, except AuditEventType.DoesNotExist:
) return None
return event
@receiver(user_logged_in) @receiver(user_logged_in)
def on_user_logged_in(sender, request, user: User, **kwargs): def on_user_logged_in(sender, request, user: User, **kwargs):
event = _get_or_create_event("user_login", "User logged in", AuditEventType.Severity.INFO) event = _get_event("user_login")
if event is None:
return
AuditLog.objects.create( AuditLog.objects.create(
created_at=timezone.now(), created_at=timezone.now(),
actor=user, actor=user,
@@ -37,7 +38,9 @@ def on_user_logged_in(sender, request, user: User, **kwargs):
@receiver(user_logged_out) @receiver(user_logged_out)
def on_user_logged_out(sender, request, user: User, **kwargs): def on_user_logged_out(sender, request, user: User, **kwargs):
event = _get_or_create_event("user_logout", "User logged out", AuditEventType.Severity.INFO) event = _get_event("user_logout")
if event is None:
return
AuditLog.objects.create( AuditLog.objects.create(
created_at=timezone.now(), created_at=timezone.now(),
actor=user, actor=user,

View File

@@ -0,0 +1,93 @@
(function () {
function parseSuggestions(textarea, key) {
try {
var raw = textarea.dataset[key];
return raw ? JSON.parse(raw) : [];
} catch (err) {
return [];
}
}
function splitLines(value) {
return (value || "")
.split(/\r?\n/)
.map(function (line) {
return line.trim();
})
.filter(function (line) {
return line.length > 0;
});
}
function appendLine(textarea, value) {
var lines = splitLines(textarea.value);
if (lines.indexOf(value) !== -1) {
return;
}
lines.push(value);
textarea.value = lines.join("\n");
textarea.dispatchEvent(new Event("change", { bubbles: true }));
}
document.addEventListener("DOMContentLoaded", function () {
var textarea = document.getElementById("id_endpoints_text");
var kindSelect = document.getElementById("id_kind");
if (!textarea || !kindSelect) {
return;
}
var apiSuggestions = parseSuggestions(textarea, "apiSuggestions");
var wsSuggestions = parseSuggestions(textarea, "wsSuggestions");
var container = document.createElement("div");
container.className = "audit-endpoint-suggestions";
container.style.marginTop = "0.5rem";
var title = document.createElement("div");
title.style.fontWeight = "600";
title.style.marginBottom = "0.25rem";
title.textContent = "Suggested endpoints";
container.appendChild(title);
var list = document.createElement("div");
list.style.display = "flex";
list.style.flexWrap = "wrap";
list.style.gap = "0.25rem";
container.appendChild(list);
textarea.parentNode.insertBefore(container, textarea.nextSibling);
function currentSuggestions() {
return kindSelect.value === "websocket" ? wsSuggestions : apiSuggestions;
}
function renderSuggestions() {
var suggestions = currentSuggestions();
list.innerHTML = "";
if (!suggestions || suggestions.length === 0) {
var empty = document.createElement("span");
empty.textContent = "No endpoint suggestions were found.";
empty.style.opacity = "0.7";
list.appendChild(empty);
return;
}
suggestions.slice(0, 40).forEach(function (suggestion) {
var button = document.createElement("button");
button.type = "button";
button.textContent = suggestion;
button.style.padding = "0.2rem 0.45rem";
button.style.borderRadius = "999px";
button.style.border = "1px solid #d1d5db";
button.style.background = "#f9fafb";
button.style.cursor = "pointer";
button.addEventListener("click", function () {
appendLine(textarea, suggestion);
});
list.appendChild(button);
});
}
kindSelect.addEventListener("change", renderSuggestions);
renderSuggestions();
});
})();

View File

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from apps.audit.matching import find_matching_event_type
from apps.audit.middleware import ApiAuditLogMiddleware
from apps.audit.models import AuditEventType, AuditLog
class ApiAuditMiddlewareTests(TestCase):
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.middleware = ApiAuditLogMiddleware(lambda request: HttpResponse("ok"))
def _call(self, method: str, path: str, ip: str = "203.0.113.5") -> None:
request = self.factory.generic(method, path)
request.META["REMOTE_ADDR"] = ip
self.middleware(request)
def test_no_matching_event_type_creates_no_logs_or_event_types(self) -> None:
self._call("GET", "/api/auto/")
self.assertEqual(AuditEventType.objects.count(), 0)
self.assertEqual(AuditLog.objects.count(), 0)
def test_matching_event_type_creates_log(self) -> None:
event_type = AuditEventType.objects.create(
key="api_test",
title="API test",
kind=AuditEventType.Kind.API,
endpoints=["/api/test/"],
)
self._call("GET", "/api/test/")
log = AuditLog.objects.get()
self.assertEqual(log.event_type_id, event_type.id)
self.assertEqual(log.source, AuditLog.Source.API)
self.assertEqual(log.severity, event_type.default_severity)
def test_ip_whitelist_blocks_and_allows(self) -> None:
AuditEventType.objects.create(
key="api_whitelist",
title="API whitelist",
kind=AuditEventType.Kind.API,
endpoints=["/api/whitelist/"],
ip_whitelist_enabled=True,
ip_whitelist=["203.0.113.10"],
)
self._call("GET", "/api/whitelist/", ip="203.0.113.5")
self.assertEqual(AuditLog.objects.count(), 0)
self._call("GET", "/api/whitelist/", ip="203.0.113.10")
self.assertEqual(AuditLog.objects.count(), 1)
def test_ip_blacklist_blocks(self) -> None:
AuditEventType.objects.create(
key="api_blacklist",
title="API blacklist",
kind=AuditEventType.Kind.API,
endpoints=["/api/blacklist/"],
ip_blacklist_enabled=True,
ip_blacklist=["203.0.113.5"],
)
self._call("GET", "/api/blacklist/", ip="203.0.113.5")
self.assertEqual(AuditLog.objects.count(), 0)
class AuditEventMatchingTests(TestCase):
def test_websocket_event_type_can_match(self) -> None:
event_type = AuditEventType.objects.create(
key="ws_shell",
title="WebSocket shell",
kind=AuditEventType.Kind.WEBSOCKET,
endpoints=["/ws/servers/*/shell/"],
)
matched = find_matching_event_type(
kind=AuditEventType.Kind.WEBSOCKET,
method="GET",
route="/ws/servers/123/shell/",
path="/ws/servers/123/shell/",
ip="203.0.113.10",
)
self.assertIsNotNone(matched)
self.assertEqual(matched.id, event_type.id)

View File

@@ -42,3 +42,51 @@ def get_request_id(request) -> str:
or request.META.get("HTTP_X_CORRELATION_ID") or request.META.get("HTTP_X_CORRELATION_ID")
or "" or ""
) )
def _get_scope_header(scope, header_name: str) -> str | None:
headers = scope.get("headers") if scope else None
if not headers:
return None
target = header_name.lower().encode("latin-1")
for key, value in headers:
if key.lower() == target:
try:
return value.decode("latin-1")
except Exception:
return None
return None
def get_client_ip_from_scope(scope) -> str | None:
if not scope:
return None
x_real_ip = _normalize_ip(_get_scope_header(scope, "x-real-ip"))
if x_real_ip:
return x_real_ip
forwarded_for = _get_scope_header(scope, "x-forwarded-for") or ""
if forwarded_for:
for part in forwarded_for.split(","):
ip = _normalize_ip(part)
if ip:
return ip
client = scope.get("client")
if isinstance(client, (list, tuple)) and client:
return _normalize_ip(str(client[0]))
return None
def get_request_id_from_scope(scope) -> str:
if not scope:
return ""
return (
_get_scope_header(scope, "x-request-id")
or _get_scope_header(scope, "x-correlation-id")
or ""
)
def get_user_agent_from_scope(scope) -> str:
if not scope:
return ""
return _get_scope_header(scope, "user-agent") or ""

View File

@@ -20,7 +20,6 @@ class Command(BaseCommand):
for perm in ( for perm in (
"access.view_accessrequest", "access.view_accessrequest",
"access.change_accessrequest", "access.change_accessrequest",
"access.delete_accessrequest",
): ):
assign_perm(perm, access_request.requester, access_request) assign_perm(perm, access_request.requester, access_request)
assign_default_object_permissions(access_request) assign_default_object_permissions(access_request)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from django.http import HttpRequest, HttpResponse
from .views import disguised_not_found
class DisguiseNotFoundMiddleware:
"""Mask 404 responses with a less-informative alternative."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
if getattr(response, "status_code", None) != 404:
return response
# Replace all 404 responses, even when DEBUG=True, because Django's
# handler404 is bypassed in debug mode.
return disguised_not_found(request)

View File

@@ -5,11 +5,9 @@ from guardian.shortcuts import assign_perm
from ninja.errors import HttpError from ninja.errors import HttpError
ROLE_ADMIN = "administrator" ROLE_ADMIN = "administrator"
ROLE_OPERATOR = "operator"
ROLE_AUDITOR = "auditor"
ROLE_USER = "user" ROLE_USER = "user"
ROLE_ORDER = (ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR, ROLE_USER) ROLE_ORDER = (ROLE_ADMIN, ROLE_USER)
ROLE_ALL = ROLE_ORDER ROLE_ALL = ROLE_ORDER
ROLE_ALIASES = {"admin": ROLE_ADMIN} ROLE_ALIASES = {"admin": ROLE_ADMIN}
ROLE_INPUTS = tuple(sorted(set(ROLE_ORDER) | set(ROLE_ALIASES.keys()))) ROLE_INPUTS = tuple(sorted(set(ROLE_ORDER) | set(ROLE_ALIASES.keys())))
@@ -20,21 +18,7 @@ def _model_perms(app_label: str, model: str, actions: list[str]) -> list[str]:
ROLE_PERMISSIONS = { ROLE_PERMISSIONS = {
ROLE_ADMIN: [], ROLE_ADMIN: [],
ROLE_OPERATOR: [
*_model_perms("servers", "server", ["view"]),
*_model_perms("access", "accessrequest", ["add", "view", "change", "delete"]),
*_model_perms("keys", "sshkey", ["add", "view", "change", "delete"]),
*_model_perms("telemetry", "telemetryevent", ["add", "view"]),
*_model_perms("audit", "auditlog", ["view"]),
*_model_perms("audit", "auditeventtype", ["view"]),
*_model_perms("auth", "user", ["add", "view"]),
],
ROLE_AUDITOR: [
*_model_perms("audit", "auditlog", ["view"]),
*_model_perms("audit", "auditeventtype", ["view"]),
],
ROLE_USER: [ ROLE_USER: [
*_model_perms("servers", "server", ["view"]),
*_model_perms("access", "accessrequest", ["add"]), *_model_perms("access", "accessrequest", ["add"]),
*_model_perms("keys", "sshkey", ["add"]), *_model_perms("keys", "sshkey", ["add"]),
], ],
@@ -132,9 +116,6 @@ def set_user_role(user, role: str) -> str:
if canonical == ROLE_ADMIN: if canonical == ROLE_ADMIN:
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
elif canonical in {ROLE_OPERATOR, ROLE_AUDITOR}:
user.is_staff = True
user.is_superuser = False
else: else:
user.is_staff = False user.is_staff = False
user.is_superuser = False user.is_superuser = False

27
app/apps/core/views.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.views.decorators.cache import never_cache
@never_cache
def disguised_not_found(request: HttpRequest, exception=None) -> HttpResponse:
"""Return a less-informative response for unknown endpoints."""
path = request.path or ""
accepts = (request.META.get("HTTP_ACCEPT") or "").lower()
# Treat anything that looks API-like as a probe and return a generic
# auth-style response rather than a 404 page.
is_api_like = path.startswith("/api/") or "application/json" in accepts
if is_api_like:
# Avoid a 404 response for unknown API paths.
return JsonResponse({"detail": "Unauthorized."}, status=401)
try:
# For browser traffic, redirect to a known entry point so the
# response shape is predictable and uninformative.
target = reverse("servers:dashboard")
except Exception:
target = "/"
return HttpResponseRedirect(target)

View File

@@ -1,7 +1,14 @@
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin try:
from unfold.contrib.guardian.admin import GuardedModelAdmin
except ImportError: # Fallback for older Unfold builds without guardian admin shim.
from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from .models import SSHKey class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass
from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
@admin.register(SSHKey) @admin.register(SSHKey)
@@ -10,3 +17,21 @@ class SSHKeyAdmin(GuardedModelAdmin):
list_filter = ("is_active", "key_type") list_filter = ("is_active", "key_type")
search_fields = ("name", "user__username", "user__email", "fingerprint") search_fields = ("name", "user__username", "user__email", "fingerprint")
ordering = ("-created_at",) ordering = ("-created_at",)
@admin.register(SSHCertificateAuthority)
class SSHCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "fingerprint", "is_active", "created_at", "revoked_at")
list_filter = ("is_active",)
search_fields = ("name", "fingerprint")
readonly_fields = ("created_at", "revoked_at", "fingerprint", "public_key", "private_key")
ordering = ("-created_at",)
@admin.register(SSHCertificate)
class SSHCertificateAdmin(admin.ModelAdmin):
list_display = ("id", "user", "key", "serial", "is_active", "valid_before", "created_at")
list_filter = ("is_active",)
search_fields = ("user__username", "user__email", "serial")
readonly_fields = ("created_at", "revoked_at", "certificate")
ordering = ("-created_at",)

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
import os
import re
import secrets
import subprocess
import tempfile
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
from .utils import render_system_username
def get_active_ca(created_by=None) -> SSHCertificateAuthority:
# Reuse the most recent active CA, or lazily create one if missing.
ca = (
SSHCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
.order_by("-created_at")
.first()
)
if not ca:
ca = SSHCertificateAuthority(created_by=created_by)
ca.ensure_material()
ca.save()
return ca
def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
if not key or not key.user_id:
raise ValueError("key must have a user")
ca = get_active_ca(created_by=created_by)
# Principal must match the system account used for SSH logins.
principal = render_system_username(key.user.username, key.user_id)
now = timezone.now()
valid_before = now + timedelta(days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS)
# Serial should be unique and non-guessable for audit purposes.
serial = secrets.randbits(63)
safe_name = _sanitize_label(key.name or "key")
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_public_key=ca.public_key,
public_key=key.public_key,
identity=identity,
principal=principal,
serial=serial,
validity_days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS,
comment=identity,
)
cert, _ = SSHCertificate.objects.update_or_create(
key=key,
defaults={
"user": key.user,
"certificate": cert_text,
"serial": serial,
"principals": [principal],
"valid_after": now,
"valid_before": valid_before,
"revoked_at": None,
"is_active": True,
},
)
return cert
def revoke_certificate_for_key(key: SSHKey) -> None:
if not key:
return
try:
cert = key.certificate
except SSHCertificate.DoesNotExist:
return
# Mark the cert as revoked but keep the record for audit/history.
cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"])
def _sign_public_key(
ca_private_key: str,
ca_public_key: str,
public_key: str,
identity: str,
principal: str,
serial: int,
validity_days: int,
comment: str,
validity_override: str | None = None,
) -> str:
if not ca_private_key or not ca_public_key:
raise RuntimeError("CA material missing")
# Write key material into a temp dir to avoid persisting secrets.
with tempfile.TemporaryDirectory() as tmpdir:
ca_path = os.path.join(tmpdir, "user_ca")
pubkey_path = os.path.join(tmpdir, "user.pub")
_write_file(ca_path, ca_private_key, 0o600)
_write_file(ca_path + ".pub", ca_public_key.strip() + "\n", 0o644)
pubkey_with_comment = _ensure_comment(public_key, comment)
_write_file(pubkey_path, pubkey_with_comment + "\n", 0o644)
# Use ssh-keygen to sign the public key with the CA.
cmd = [
"ssh-keygen",
"-s",
ca_path,
"-I",
identity,
"-n",
principal,
"-V",
validity_override or f"+{validity_days}d",
"-z",
str(serial),
pubkey_path,
]
try:
result = subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
# ssh-keygen writes the cert alongside the input pubkey.
cert_path = pubkey_path
if cert_path.endswith(".pub"):
cert_path = cert_path[: -len(".pub")]
cert_path += "-cert.pub"
if not os.path.exists(cert_path):
stderr = result.stderr.decode("utf-8", "ignore")
raise RuntimeError(f"ssh-keygen output missing: {cert_path} {stderr}")
with open(cert_path, "r", encoding="utf-8") as handle:
return handle.read().strip()
def _ensure_comment(public_key: str, comment: str) -> str:
# Preserve the key type and base64 payload; replace/append only the comment.
parts = (public_key or "").strip().split()
if len(parts) < 2:
return public_key.strip()
key_type, key_b64 = parts[0], parts[1]
if not comment:
return f"{key_type} {key_b64}"
return f"{key_type} {key_b64} {comment}"
def _sanitize_label(value: str) -> str:
# Reduce label to a safe, lowercase token for certificate identity.
cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", (value or "").strip())
cleaned = cleaned.strip("-_")
if cleaned:
return cleaned.lower()
return "key"
def _write_file(path: str, data: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as handle:
handle.write(data)
# Apply explicit permissions for key material.
os.chmod(path, mode)

View File

@@ -0,0 +1,86 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("keys", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SSHCertificateAuthority",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(default="Keywarden User SSH CA", max_length=128)),
("public_key", models.TextField(blank=True)),
("private_key", models.TextField(blank=True)),
("fingerprint", models.CharField(blank=True, max_length=128)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ssh_certificate_authorities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "SSH certificate authority",
"verbose_name_plural": "SSH certificate authorities",
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="SSHCertificate",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("certificate", models.TextField()),
("serial", models.BigIntegerField()),
("principals", models.JSONField(blank=True, default=list)),
("valid_after", models.DateTimeField()),
("valid_before", models.DateTimeField()),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
(
"key",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="certificate",
to="keys.sshkey",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ssh_certificates",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "SSH certificate",
"verbose_name_plural": "SSH certificates",
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="sshcertificate",
index=models.Index(fields=["user", "is_active"], name="keys_cert_user_active_idx"),
),
migrations.AddIndex(
model_name="sshcertificate",
index=models.Index(fields=["valid_before"], name="keys_cert_valid_before_idx"),
),
]

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
import hashlib import hashlib
import os
import subprocess
import tempfile
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -61,6 +64,107 @@ class SSHKey(models.Model):
def revoke(self) -> None: def revoke(self) -> None:
self.is_active = False self.is_active = False
self.revoked_at = timezone.now() self.revoked_at = timezone.now()
try:
cert = self.certificate
except SSHCertificate.DoesNotExist:
return
cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"])
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.name} ({self.user_id})" return f"{self.name} ({self.user_id})"
class SSHCertificateAuthority(models.Model):
name = models.CharField(max_length=128, default="Keywarden User SSH CA")
public_key = models.TextField(blank=True)
private_key = models.TextField(blank=True)
fingerprint = models.CharField(max_length=128, blank=True)
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="ssh_certificate_authorities",
)
class Meta:
verbose_name = "SSH certificate authority"
verbose_name_plural = "SSH certificate authorities"
ordering = ["-created_at"]
def __str__(self) -> str:
status = "active" if self.is_active and not self.revoked_at else "revoked"
return f"{self.name} ({status})"
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def ensure_material(self) -> None:
if self.public_key and self.private_key:
if not self.fingerprint:
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
return
with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "keywarden_user_ca")
cmd = [
"ssh-keygen",
"-t",
"ed25519",
"-f",
key_path,
"-C",
self.name,
"-N",
"",
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
with open(key_path, "r", encoding="utf-8") as handle:
self.private_key = handle.read()
with open(key_path + ".pub", "r", encoding="utf-8") as handle:
self.public_key = handle.read().strip()
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
class SSHCertificate(models.Model):
key = models.OneToOneField(
SSHKey, on_delete=models.CASCADE, related_name="certificate"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ssh_certificates"
)
certificate = models.TextField()
serial = models.BigIntegerField()
principals = models.JSONField(default=list, blank=True)
valid_after = models.DateTimeField()
valid_before = models.DateTimeField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
revoked_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
class Meta:
verbose_name = "SSH certificate"
verbose_name_plural = "SSH certificates"
indexes = [
models.Index(fields=["user", "is_active"], name="keys_cert_user_active_idx"),
models.Index(fields=["valid_before"], name="keys_cert_valid_before_idx"),
]
ordering = ["-created_at"]
def revoke(self) -> None:
self.is_active = False
self.revoked_at = timezone.now()
def __str__(self) -> str:
return f"{self.user_id} ({self.serial})"

33
app/apps/keys/utils.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import re
from django.conf import settings
MAX_USERNAME_LEN = 32
_SANITIZE_RE = re.compile(r"[^a-z0-9_-]")
def render_system_username(username: str, user_id: int) -> str:
# Render from template and then sanitize to an OS-safe username.
template = settings.KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE
raw = template.replace("{{username}}", username or "")
raw = raw.replace("{{user_id}}", str(user_id))
cleaned = sanitize_username(raw)
if len(cleaned) > MAX_USERNAME_LEN:
cleaned = cleaned[:MAX_USERNAME_LEN]
if cleaned:
return cleaned
# Fall back to a deterministic, non-empty username.
return f"kw_{user_id}"
def sanitize_username(raw: str) -> str:
# Normalize to lowercase and replace disallowed characters.
raw = (raw or "").lower()
raw = _SANITIZE_RE.sub("_", raw)
raw = raw.strip("-_")
if raw.startswith("-"):
# Avoid leading dash, which can be interpreted as a CLI flag.
return "kw" + raw
return raw

View File

@@ -1,6 +1,13 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from guardian.admin import GuardedModelAdmin try:
from unfold.contrib.guardian.admin import GuardedModelAdmin
except ImportError: # Fallback for older Unfold builds without guardian admin shim.
from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass
from .models import AgentCertificateAuthority, EnrollmentToken, Server from .models import AgentCertificateAuthority, EnrollmentToken, Server
@@ -59,12 +66,11 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
list_display = ("name", "is_active", "created_at", "revoked_at") list_display = ("name", "is_active", "created_at", "revoked_at")
list_filter = ("is_active", "created_at", "revoked_at") list_filter = ("is_active", "created_at", "revoked_at")
search_fields = ("name", "fingerprint") search_fields = ("name", "fingerprint")
readonly_fields = ("fingerprint", "serial", "created_at", "revoked_at", "created_by") readonly_fields = ("cert_pem", "fingerprint", "serial", "created_at", "revoked_at", "created_by")
fields = ( fields = (
"name", "name",
"is_active", "is_active",
"cert_pem", "cert_pem",
"key_pem",
"fingerprint", "fingerprint",
"serial", "serial",
"created_by", "created_by",

View File

@@ -0,0 +1,295 @@
from __future__ import annotations
import asyncio
import os
import secrets
import subprocess
import tempfile
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.utils import timezone
from apps.audit.matching import find_matching_event_type
from apps.audit.models import AuditEventType, AuditLog
from apps.audit.utils import (
get_client_ip_from_scope,
get_request_id_from_scope,
get_user_agent_from_scope,
)
from apps.keys.certificates import get_active_ca, _sign_public_key
from apps.keys.utils import render_system_username
from apps.servers.models import Server, ServerAccount
from apps.servers.permissions import user_can_shell
class ShellConsumer(AsyncWebsocketConsumer):
async def connect(self):
# Initialize per-connection state; this consumer is stateful
# across the WebSocket lifecycle.
self.proc = None
self.reader_task = None
self.tempdir = None
self.system_username = ""
self.shell_target = ""
self.server_id: int | None = None
# Reject unauthenticated connections before any side effects.
user = self.scope.get("user")
if not user or not getattr(user, "is_authenticated", False):
await self.close(code=4401)
return
server_id = self.scope.get("url_route", {}).get("kwargs", {}).get("server_id")
if not server_id:
await self.close(code=4400)
return
# Resolve the server and enforce object-level permissions before
# accepting the socket.
server = await self._get_server(user, int(server_id))
if not server:
await self.close(code=4404)
return
self.server_id = server.id
can_shell = await self._can_shell(user, server)
if not can_shell:
await self.close(code=4403)
return
# Resolve the per-user system account name and the best reachable host.
system_username = await self._get_system_username(user, server)
shell_target = server.hostname or server.ipv4 or server.ipv6
if not system_username or not shell_target:
await self.close(code=4400)
return
self.system_username = system_username
self.shell_target = shell_target
# Only accept the socket after all authn/authz checks have passed.
await self.accept()
# Audit the WebSocket connection as an explicit, opt-in event.
await self._audit_websocket_event(user=user, action="connect", metadata={"server_id": server.id})
await self.send(text_data="Connecting...\r\n")
try:
await self._start_ssh(user)
except Exception:
await self.send(text_data="Connection failed.\r\n")
await self.close()
async def disconnect(self, code):
user = self.scope.get("user")
if user and getattr(user, "is_authenticated", False):
await self._audit_websocket_event(
user=user,
action="disconnect",
metadata={"code": code, "server_id": self.server_id},
)
if self.reader_task:
self.reader_task.cancel()
self.reader_task = None
if self.proc and self.proc.returncode is None:
self.proc.terminate()
try:
await asyncio.wait_for(self.proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
self.proc.kill()
if self.tempdir:
self.tempdir.cleanup()
self.tempdir = None
async def receive(self, text_data=None, bytes_data=None):
if not self.proc or not self.proc.stdin:
return
# Forward WebSocket payloads directly to the SSH subprocess stdin.
if bytes_data is not None:
data = bytes_data
elif text_data is not None:
data = text_data.encode("utf-8")
else:
return
if data:
self.proc.stdin.write(data)
await self.proc.stdin.drain()
async def _start_ssh(self, user):
# Generate a short-lived keypair + SSH certificate and then
# bridge the WebSocket to an SSH subprocess.
# Prefer tmpfs when available so the private key never hits disk.
temp_base = "/dev/shm" if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None
self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-", dir=temp_base)
key_path, cert_path = await asyncio.to_thread(
_generate_session_keypair,
self.tempdir.name,
user,
self.system_username,
)
ssh_host = _format_ssh_host(self.shell_target)
# Use a locked-down, non-interactive SSH invocation suitable for websockets.
command = [
"ssh",
"-tt",
"-i",
key_path,
"-o",
f"CertificateFile={cert_path}",
"-o",
"BatchMode=yes",
"-o",
"PasswordAuthentication=no",
"-o",
"KbdInteractiveAuthentication=no",
"-o",
"ChallengeResponseAuthentication=no",
"-o",
"PreferredAuthentications=publickey",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"VerifyHostKeyDNS=no",
"-o",
"LogLevel=ERROR",
f"{self.system_username}@{ssh_host}",
"/bin/bash",
]
self.proc = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
# Delete key material immediately after the SSH process has it open.
for path in (key_path, cert_path, f"{key_path}.pub"):
try:
os.remove(path)
except FileNotFoundError:
continue
except Exception:
pass
self.reader_task = asyncio.create_task(self._stream_output())
async def _stream_output(self):
if not self.proc or not self.proc.stdout:
return
# Pump subprocess output until EOF, then close the socket.
while True:
chunk = await self.proc.stdout.read(4096)
if not chunk:
break
await self.send(bytes_data=chunk)
await self.close()
@database_sync_to_async
def _get_server(self, user, server_id: int):
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
return None
if not user.has_perm("servers.view_server", server):
return None
return server
@database_sync_to_async
def _can_shell(self, user, server) -> bool:
return user_can_shell(user, server, timezone.now())
@database_sync_to_async
def _get_system_username(self, user, server) -> str:
account = ServerAccount.objects.filter(server=server, user=user).first()
if account:
return account.system_username
return render_system_username(user.username, user.id)
@database_sync_to_async
def _audit_websocket_event(self, user, action: str, metadata: dict | None = None) -> None:
try:
path = str(self.scope.get("path") or "")
client_ip = get_client_ip_from_scope(self.scope)
# Match only against explicitly configured WebSocket event types.
event_type = find_matching_event_type(
kind=AuditEventType.Kind.WEBSOCKET,
method="GET",
route=path,
path=path,
ip=client_ip,
)
if event_type is None:
return
combined_metadata = {
"action": action,
"path": path,
}
if metadata:
combined_metadata.update(metadata)
AuditLog.objects.create(
created_at=timezone.now(),
actor=user,
event_type=event_type,
message=f"WebSocket {action} {path}",
severity=event_type.default_severity,
source=AuditLog.Source.API,
ip_address=client_ip,
user_agent=get_user_agent_from_scope(self.scope),
request_id=get_request_id_from_scope(self.scope),
metadata=combined_metadata,
)
except Exception:
# Auditing is best-effort; never fail the shell session.
return
def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str, str]:
# Create an ephemeral SSH keypair and sign it with the active CA so
# the user gets time-scoped shell access without long-lived keys.
ca = get_active_ca(created_by=user)
serial = secrets.randbits(63)
identity = f"keywarden-shell-{user.id}-{serial}"
key_path = os.path.join(tempdir, "session_key")
cmd = [
"ssh-keygen",
"-t",
"ed25519",
"-f",
key_path,
"-C",
identity,
"-N",
"",
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError as exc:
raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
# Restrict filesystem access to the private key.
os.chmod(key_path, 0o600)
pubkey_path = key_path + ".pub"
with open(pubkey_path, "r", encoding="utf-8") as handle:
public_key = handle.read().strip()
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_public_key=ca.public_key,
public_key=public_key,
identity=identity,
principal=principal,
serial=serial,
validity_days=1,
validity_override=f"+{settings.KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES}m",
comment=identity,
)
cert_path = key_path + "-cert.pub"
with open(cert_path, "w", encoding="utf-8") as handle:
handle.write(cert_text + "\n")
# Public cert is safe to be world-readable.
os.chmod(cert_path, 0o644)
return key_path, cert_path
def _format_ssh_host(host: str) -> str:
# IPv6 hosts must be wrapped in brackets for the SSH CLI.
if ":" in host and not (host.startswith("[") and host.endswith("]")):
return f"[{host}]"
return host

View File

@@ -0,0 +1,59 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("servers", "0003_agent_ca"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ServerAccount",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("system_username", models.CharField(max_length=128)),
("is_present", models.BooleanField(db_index=True, default=False)),
("last_synced_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"server",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="accounts",
to="servers.server",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="server_accounts",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Server account",
"verbose_name_plural": "Server accounts",
"ordering": ["server_id", "user_id"],
},
),
migrations.AddConstraint(
model_name="serveraccount",
constraint=models.UniqueConstraint(fields=("server", "user"), name="unique_server_account"),
),
migrations.AddIndex(
model_name="serveraccount",
index=models.Index(fields=["server", "user"], name="servers_account_user_idx"),
),
migrations.AddIndex(
model_name="serveraccount",
index=models.Index(fields=["server", "is_present"], name="servers_account_present_idx"),
),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("servers", "0004_server_account"),
]
operations = [
migrations.AlterModelOptions(
name="server",
options={
"ordering": ["display_name", "hostname", "ipv4", "ipv6"],
"permissions": [("shell_server", "Can access server shell")],
"verbose_name": "Server",
"verbose_name_plural": "Servers",
},
),
]

View File

@@ -0,0 +1,35 @@
from django.db import migrations
def remove_user_group_server_perms(apps, schema_editor):
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
ContentType = apps.get_model("contenttypes", "ContentType")
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
try:
group = Group.objects.get(name="user")
except Group.DoesNotExist:
return
try:
content_type = ContentType.objects.get(app_label="servers", model="server")
except ContentType.DoesNotExist:
return
perm_ids = Permission.objects.filter(content_type=content_type).values_list("id", flat=True)
GroupObjectPermission.objects.filter(
group_id=group.id,
permission_id__in=list(perm_ids),
).delete()
class Migration(migrations.Migration):
dependencies = [
("servers", "0005_server_shell_permission"),
("guardian", "0001_initial"),
]
operations = [
migrations.RunPython(remove_user_group_server_perms, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0006_remove_user_group_server_perms"),
]
operations = [
migrations.AddField(
model_name="server",
name="ssh_host_public_key",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="server",
name="ssh_host_fingerprint",
field=models.CharField(blank=True, max_length=128),
),
]

View File

@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("servers", "0007_server_host_key"),
]
operations = [
migrations.RemoveField(
model_name="server",
name="ssh_host_fingerprint",
),
migrations.RemoveField(
model_name="server",
name="ssh_host_public_key",
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("servers", "0008_remove_server_host_key"),
]
operations = [
migrations.AddField(
model_name="server",
name="last_heartbeat_at",
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AddField(
model_name="server",
name="last_ping_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@@ -28,6 +28,8 @@ 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)
last_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -35,6 +37,9 @@ class Server(models.Model):
ordering = ["display_name", "hostname", "ipv4", "ipv6"] ordering = ["display_name", "hostname", "ipv4", "ipv6"]
verbose_name = "Server" verbose_name = "Server"
verbose_name_plural = "Servers" verbose_name_plural = "Servers"
permissions = [
("shell_server", "Can access server shell"),
]
def __str__(self) -> str: def __str__(self) -> str:
primary = self.hostname or self.ipv4 or self.ipv6 or "unassigned" primary = self.hostname or self.ipv4 or self.ipv6 or "unassigned"
@@ -157,3 +162,30 @@ class AgentCertificateAuthority(models.Model):
self.key_pem = key_pem self.key_pem = key_pem
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex() self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
self.serial = format(cert.serial_number, "x") self.serial = format(cert.serial_number, "x")
class ServerAccount(models.Model):
server = models.ForeignKey(Server, on_delete=models.CASCADE, related_name="accounts")
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="server_accounts"
)
system_username = models.CharField(max_length=128)
is_present = models.BooleanField(default=False, db_index=True)
last_synced_at = models.DateTimeField(default=timezone.now, editable=False)
created_at = models.DateTimeField(default=timezone.now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Server account"
verbose_name_plural = "Server accounts"
constraints = [
models.UniqueConstraint(fields=["server", "user"], name="unique_server_account")
]
indexes = [
models.Index(fields=["server", "user"], name="servers_account_user_idx"),
models.Index(fields=["server", "is_present"], name="servers_account_present_idx"),
]
ordering = ["server_id", "user_id"]
def __str__(self) -> str:
return f"{self.system_username} ({self.server_id})"

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from django.db.models import Q
from django.utils import timezone
from apps.access.models import AccessRequest
def user_can_shell(user, server, now=None) -> bool:
if user.has_perm("servers.shell_server", server):
return True
if now is None:
now = timezone.now()
return (
AccessRequest.objects.filter(
requester=user,
server=server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.exists()
)

View File

@@ -0,0 +1,114 @@
<div class="space-y-4">
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm text-gray-500">
<li class="inline-flex items-center">
<a href="{% url 'servers:dashboard' %}" class="inline-flex items-center gap-1 font-medium text-gray-600 hover:text-blue-700">
<svg class="h-4 w-4" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 3.172 2 10v7a1 1 0 0 0 1 1h5v-5h4v5h5a1 1 0 0 0 1-1v-7l-8-6.828Z"></path>
</svg>
Servers
</a>
</li>
<li class="inline-flex items-center">
<svg class="h-4 w-4 text-gray-400" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M7.05 4.55a1 1 0 0 1 1.4-1.42l6 5.9a1 1 0 0 1 0 1.42l-6 5.9a1 1 0 1 1-1.4-1.42L12.5 10 7.05 4.55Z"></path>
</svg>
<span class="ml-1 font-medium text-gray-700">{{ server.display_name }}</span>
</li>
</ol>
</nav>
<div class="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-700 text-lg font-semibold text-white shadow-sm">
{{ server.initial }}
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">{{ server.display_name }}</h1>
<p class="text-sm text-gray-500">
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<button
type="button"
data-tooltip-target="server-header-status-{{ server.id }}"
class="{% if server_status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ server_status.label }}: {{ server_status.detail }}
</button>
<div
id="server-header-status-{{ server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ server_status.label }}: {{ server_status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if server_status.ping_ms is not None %}{{ server_status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if server_status.heartbeat_at %}{{ server_status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<a href="{% url 'servers:dashboard' %}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50">
Back to servers
</a>
</div>
</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">
<a
href="{% url 'servers:detail' server.id %}"
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Details
</a>
<a
href="{% url 'servers:audit' server.id %}"
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Audit
</a>
{% if can_shell %}
<a
href="{% url 'servers:shell' server.id %}"
class="{% if active_tab == 'shell' %}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 %}"
>
Shell
</a>
{% endif %}
<a
href="{% url 'servers:settings' server.id %}"
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
>
Settings
</a>
</nav>

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Audit • {{ 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">
<div>
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12h18M7 8v8M17 8v8" />
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Servers • 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">Servers</h1>
<p class="mt-1 text-sm text-gray-500">Review the servers you can access and their certificate status.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
{{ servers|length }} total
</span>
</div>
{% if servers %}
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %}
<article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
{{ item.server.initial }}
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
<p class="text-xs text-gray-500">
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if item.last_accessed %}
{{ item.last_accessed|date:"M j, Y H:i" }}
{% else %}
{% endif %}
</dd>
</div>
</dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<span>Certificates and access</span>
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-gray-200 bg-white p-10 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}{{ server.display_name }} • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
{% include "servers/_header.html" %}
<section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Server details</h2>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Hostname</dt>
<dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>IPv4</dt>
<dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>IPv6</dt>
<dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Account & certificate</h2>
<p class="mt-1 text-sm text-gray-500">Credentials and certificate download options.</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Access</span>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Account name</dt>
<dd class="font-medium text-gray-900">
{% if system_username %}
{{ system_username }}
{% else %}
Unknown
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>Account status</dt>
<dd class="font-medium text-gray-900">
{% if account_present is None %}
Unknown
{% elif account_present %}
Present
{% else %}
Not on server
{% endif %}
</dd>
</div>
<div class="flex flex-col gap-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt>Certificate</dt>
<dd class="font-medium text-gray-900">
{% if certificate_key_id %}
<div class="flex flex-wrap items-center gap-2">
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
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 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
</div>
{% else %}
<span class="text-xs font-semibold text-gray-500">Upload a key to download</span>
{% endif %}
</dd>
</div>
</dl>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-3">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">Access</h2>
<p class="mt-1 text-sm text-gray-500">Review access windows and last usage.</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">Usage</span>
</div>
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if expires_at %}
{{ expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if last_accessed %}
{{ last_accessed|date:"M j, Y H:i" }}
{% else %}
{% endif %}
</dd>
</div>
</dl>
</div>
</section>
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.alert("Certificate regenerated.");
})
.catch(function (err) {
window.alert(err.message);
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Settings • {{ 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">
<div>
<h2 class="text-lg font-semibold text-gray-900">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
</div>
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,389 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Shell • {{ server.display_name }} • Keywarden{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'vendor/xterm/xterm.css' %}">
{% if is_popout %}
<style>
body.popout-shell main {
max-width: none !important;
padding: 0 !important;
}
</style>
{% endif %}
{% endblock %}
{% block content %}
{% if is_popout %}
<div class="w-screen">
<div id="shell-popout-shell" class="w-full rounded-2xl border border-gray-200 bg-slate-950 shadow-sm">
<div id="shell-terminal" class="h-full w-full p-3"></div>
</div>
</div>
{% else %}
<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-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Shell access</h2>
<p class="mt-1 text-sm text-gray-500">
Connect with your private key and the signed certificate for this server.
</p>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
id="shell-popout"
data-popout-url="{% url 'servers:shell' server.id %}?popout=1"
>
Pop out terminal
</button>
</div>
<div class="mt-5 grid gap-4 text-sm text-gray-600 lg:grid-cols-2">
<dl class="space-y-4">
<div class="flex items-center justify-between">
<dt>Account name</dt>
<dd class="font-medium text-gray-900">
{% if system_username %}
{{ system_username }}
{% else %}
Unknown
{% endif %}
</dd>
</div>
<div class="flex items-center justify-between">
<dt>Host</dt>
<dd class="font-medium text-gray-900">
{% if shell_target %}
{{ shell_target }}
{% else %}
Unknown
{% endif %}
</dd>
</div>
</dl>
{% if shell_command %}
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">SSH command</span>
<button
type="button"
class="text-xs font-semibold text-blue-700 hover:underline"
data-copy-target="shell-command"
>
Copy command
</button>
</div>
<code class="mt-3 block break-all rounded-lg bg-white p-3 text-xs text-gray-800" id="shell-command">{{ shell_command }}</code>
<div class="mt-4 flex flex-wrap items-center gap-2">
{% if certificate_key_id %}
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ certificate_key_id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
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 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ certificate_key_id }}"
>
Regenerate
</button>
{% endif %}
<span class="text-xs text-gray-500">Use the command above for local SSH.</span>
</div>
</div>
{% else %}
<p class="text-sm text-gray-600">Upload a key to enable downloads and a local SSH command.</p>
{% endif %}
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Browser terminal</h2>
<p class="mt-1 text-sm text-gray-500">
Launch a proxied terminal session to the target host in your browser.
</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">Beta</span>
<button
type="button"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
id="shell-start"
>
Start terminal
</button>
</div>
</div>
<div class="mt-4 rounded-xl border border-gray-200 bg-slate-950 p-2">
<div id="shell-terminal" class="h-96"></div>
</div>
<p class="mt-3 text-xs text-gray-500">
Sessions are proxied through Keywarden and end when this page closes.
</p>
</section>
</div>
{% endif %}
<script src="{% static 'vendor/xterm/xterm.js' %}"></script>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
}
return "";
}
function handleDownload(event) {
var button = event.currentTarget;
var url = button.getAttribute("data-download-url");
if (!url) {
return;
}
window.location.href = url;
}
function handleRegenerate(event) {
var button = event.currentTarget;
var keyId = button.getAttribute("data-key-id");
if (!keyId) {
return;
}
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrf,
},
})
.then(function (response) {
if (!response.ok) {
throw new Error("Certificate regeneration failed.");
}
window.alert("Certificate regenerated.");
})
.catch(function (err) {
window.alert(err.message);
});
}
function handleCopy(event) {
var targetId = event.currentTarget.getAttribute("data-copy-target");
if (!targetId) {
return;
}
var node = document.getElementById(targetId);
if (!node) {
return;
}
var text = node.textContent || "";
if (!navigator.clipboard || !text) {
return;
}
navigator.clipboard.writeText(text).then(function () {
window.alert("Command copied.");
});
}
var popout = document.getElementById("shell-popout");
if (popout) {
popout.addEventListener("click", function () {
var url = popout.getAttribute("data-popout-url");
if (!url) {
return;
}
window.open(url, "_blank", "width=900,height=700");
});
}
var downloadButtons = document.querySelectorAll("[data-download-url]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
}
var buttons = document.querySelectorAll(".js-regenerate-cert");
for (var j = 0; j < buttons.length; j += 1) {
buttons[j].addEventListener("click", handleRegenerate);
}
var copyButtons = document.querySelectorAll("[data-copy-target]");
for (var k = 0; k < copyButtons.length; k += 1) {
copyButtons[k].addEventListener("click", handleCopy);
}
var termContainer = document.getElementById("shell-terminal");
var startButton = document.getElementById("shell-start");
var activeSocket = null;
var activeTerm = null;
var popoutShell = document.getElementById("shell-popout-shell");
var isPopout = {{ is_popout|yesno:"true,false" }};
function sizePopoutTerminal() {
if (!isPopout || !popoutShell || !termContainer) {
return;
}
var padding = 24;
var height = Math.max(320, window.innerHeight - padding);
popoutShell.style.height = height + "px";
termContainer.style.height = (height - 8) + "px";
}
function fitTerminal(term) {
if (!termContainer || !term || !term._core || !term._core._renderService) {
return;
}
var dims = term._core._renderService.dimensions;
if (!dims || !dims.css || !dims.css.cell) {
return;
}
var cellWidth = dims.css.cell.width || 9;
var cellHeight = dims.css.cell.height || 18;
if (!cellWidth || !cellHeight) {
return;
}
var cols = Math.max(20, Math.floor(termContainer.clientWidth / cellWidth));
var rows = Math.max(10, Math.floor(termContainer.clientHeight / cellHeight));
term.resize(cols, rows);
}
function setButtonState(isRunning) {
if (!startButton) {
return;
}
startButton.disabled = false;
startButton.textContent = isRunning ? "Stop terminal" : "Start terminal";
startButton.classList.toggle("bg-red-600", isRunning);
startButton.classList.toggle("hover:bg-red-700", isRunning);
startButton.classList.toggle("bg-blue-700", !isRunning);
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
}
function stopTerminal() {
if (activeSocket) {
try {
activeSocket.close();
} catch (err) {
// noop
}
}
if (termContainer) {
termContainer.dataset.started = "0";
}
activeSocket = null;
activeTerm = null;
setButtonState(false);
}
function startTerminal() {
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
return;
}
termContainer.dataset.started = "1";
if (startButton) {
startButton.disabled = true;
startButton.textContent = "Starting...";
}
var term = new window.Terminal({
cursorBlink: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: 13,
theme: {
background: "#0b1120",
foreground: "#e2e8f0",
cursor: "#38bdf8"
}
});
term.open(termContainer);
setTimeout(function () {
fitTerminal(term);
}, 0);
var protocol = window.location.protocol === "https:" ? "wss" : "ws";
var socketUrl = protocol + "://" + window.location.host + "/ws/servers/{{ server.id }}/shell/";
var socket = new WebSocket(socketUrl);
socket.binaryType = "arraybuffer";
activeSocket = socket;
activeTerm = term;
socket.onmessage = function (event) {
if (typeof event.data === "string") {
term.write(event.data);
return;
}
var data = new Uint8Array(event.data);
var text = new TextDecoder("utf-8").decode(data);
term.write(text);
};
socket.onclose = function () {
term.write("\r\nSession closed.\r\n");
if (activeSocket === socket) {
stopTerminal();
}
};
term.onData(function (data) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});
setButtonState(true);
if (isPopout) {
var onResize = function () {
sizePopoutTerminal();
fitTerminal(term);
};
window.addEventListener("resize", onResize);
}
}
if (termContainer && window.Terminal) {
if (isPopout) {
document.body.classList.add("popout-shell");
sizePopoutTerminal();
window.addEventListener("resize", sizePopoutTerminal);
}
if (startButton) {
startButton.addEventListener("click", function () {
if (termContainer.dataset.started === "1") {
stopTerminal();
return;
}
startTerminal();
});
} else {
startTerminal();
}
} else if (termContainer) {
termContainer.textContent = "Terminal assets failed to load.";
}
})();
</script>
{% endblock %}

13
app/apps/servers/urls.py Normal file
View File

@@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "servers"
urlpatterns = [
path("", views.dashboard, name="dashboard"),
path("<int:server_id>/", views.detail, name="detail"),
path("<int:server_id>/audit/", views.audit, name="audit"),
path("<int:server_id>/shell/", views.shell, name="shell"),
path("<int:server_id>/settings/", views.settings, name="settings"),
]

230
app/apps/servers/views.py Normal file
View File

@@ -0,0 +1,230 @@
from __future__ import annotations
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import Http404
from django.shortcuts import render
from django.utils import timezone
from guardian.shortcuts import get_objects_for_user, get_perms
from apps.access.models import AccessRequest
from apps.keys.utils import render_system_username
from apps.keys.models import SSHKey
from apps.servers.models import Server, ServerAccount
from apps.servers.permissions import user_can_shell
@login_required(login_url="/accounts/login/")
def dashboard(request):
now = timezone.now()
server_qs = get_objects_for_user(
request.user,
"servers.view_server",
klass=Server,
accept_global_perms=False,
)
access_qs = (
AccessRequest.objects.select_related("server")
.filter(
requester=request.user,
status=AccessRequest.Status.APPROVED,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
)
expires_map = {}
for access in access_qs:
expires_at = access.expires_at
if access.server_id not in expires_map:
expires_map[access.server_id] = expires_at
continue
current = expires_map[access.server_id]
if current is None:
continue
if expires_at is None or expires_at > current:
expires_map[access.server_id] = expires_at
servers = []
for server in server_qs:
servers.append(
{
"server": server,
"expires_at": expires_map.get(server.id),
"last_accessed": None,
"status": _build_server_status(server, now),
}
)
context = {
"servers": servers,
}
return render(request, "servers/dashboard.html", context)
@login_required(login_url="/accounts/login/")
def detail(request, server_id: int):
now = timezone.now()
# Authorization is enforced via object-level permissions before we do
# any other server-specific work.
server = _get_server_or_404(request, server_id)
can_shell = user_can_shell(request.user, server, now)
access = (
AccessRequest.objects.filter(
requester=request.user,
server_id=server_id,
status=AccessRequest.Status.APPROVED,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.order_by("-requested_at")
.first()
)
account, system_username, certificate_key_id = _load_account_context(request, server)
context = {
"server": server,
"expires_at": access.expires_at if access else None,
"last_accessed": None,
"account_present": account.is_present if account else None,
"account_synced_at": account.last_synced_at if account else None,
"system_username": system_username,
"certificate_key_id": certificate_key_id,
"active_tab": "details",
"can_shell": can_shell,
"server_status": _build_server_status(server, now),
}
return render(request, "servers/detail.html", context)
@login_required(login_url="/accounts/login/")
def shell(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id)
# We intentionally return a 404 on denied shell access to avoid
# disclosing that the server exists but is restricted.
if not user_can_shell(request.user, server):
raise Http404("Shell access not available")
_, system_username, certificate_key_id = _load_account_context(request, server)
shell_target = server.hostname or server.ipv4 or server.ipv6 or ""
cert_filename = ""
if certificate_key_id:
cert_filename = f"keywarden-{request.user.id}-{certificate_key_id}-cert.pub"
command = ""
if shell_target and system_username and certificate_key_id:
command = (
"ssh -i /path/to/private_key "
f"-o CertificateFile=~/Downloads/{cert_filename} "
f"{system_username}@{shell_target} -t /bin/bash"
)
context = {
"server": server,
"system_username": system_username,
"certificate_key_id": certificate_key_id,
"shell_target": shell_target,
"shell_command": command,
"cert_filename": cert_filename,
"active_tab": "shell",
"is_popout": request.GET.get("popout") == "1",
"can_shell": True,
"server_status": _build_server_status(server, now),
}
return render(request, "servers/shell.html", context)
@login_required(login_url="/accounts/login/")
def audit(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id)
context = {
"server": server,
"active_tab": "audit",
"can_shell": user_can_shell(request.user, server),
"server_status": _build_server_status(server, now),
}
return render(request, "servers/audit.html", context)
@login_required(login_url="/accounts/login/")
def settings(request, server_id: int):
now = timezone.now()
server = _get_server_or_404(request, server_id)
context = {
"server": server,
"active_tab": "settings",
"can_shell": user_can_shell(request.user, server),
"server_status": _build_server_status(server, now),
}
return render(request, "servers/settings.html", context)
def _get_server_or_404(request, server_id: int) -> Server:
# Centralized object lookup + permission gate. We raise 404 for both
# missing objects and permission denials to reduce enumeration signals.
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise Http404("Server not found")
if "view_server" not in get_perms(request.user, server):
raise Http404("Server not found")
return server
def _load_account_context(request, server: Server):
# Resolve the effective system username and the currently active SSH
# key/certificate context used by the shell UI.
account = ServerAccount.objects.filter(server=server, user=request.user).first()
system_username = account.system_username if account else render_system_username(
request.user.username, request.user.id
)
active_key = SSHKey.objects.filter(user=request.user, is_active=True).order_by("-created_at").first()
certificate_key_id = active_key.id if active_key else None
return account, system_username, certificate_key_id
def _format_age_short(delta: timedelta) -> str:
seconds = max(0, int(delta.total_seconds()))
if seconds < 60:
return f"{seconds}s"
minutes = seconds // 60
rem_seconds = seconds % 60
if minutes < 60:
return f"{minutes}m {rem_seconds}s"
hours = minutes // 60
rem_minutes = minutes % 60
if hours < 48:
return f"{hours}h {rem_minutes}m {rem_seconds}s"
days = hours // 24
if days < 14:
return f"{days}d {hours % 24}h"
weeks = days // 7
return f"{weeks}w {days % 7}d"
def _build_server_status(server: Server, now):
stale_seconds = int(getattr(settings, "KEYWARDEN_HEARTBEAT_STALE_SECONDS", 120))
heartbeat_at = getattr(server, "last_heartbeat_at", None)
ping_ms = getattr(server, "last_ping_ms", None)
if heartbeat_at:
age = now - heartbeat_at
age_seconds = max(0, int(age.total_seconds()))
is_active = age_seconds <= stale_seconds
age_short = _format_age_short(age)
else:
is_active = False
age_short = "never"
label = "Active" if is_active else "Inactive"
if is_active:
detail = f"{ping_ms}ms" if ping_ms is not None else ""
else:
detail = age_short
return {
"is_active": is_active,
"label": label,
"detail": detail,
"ping_ms": ping_ms,
"age_short": age_short,
"heartbeat_at": heartbeat_at,
}

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

View File

@@ -1,6 +1,7 @@
import inspect
from typing import List, Optional from typing import List, Optional
from ninja import NinjaAPI, Router, Schema from ninja import NinjaAPI, Router, Schema, Redoc
from ninja.security import django_auth from ninja.security import django_auth
from .security import JWTAuth from .security import JWTAuth
@@ -14,34 +15,43 @@ from .routers.access import build_router as build_access_router
from .routers.telemetry import build_router as build_telemetry_router from .routers.telemetry import build_router as build_telemetry_router
from .routers.agent import build_router as build_agent_router from .routers.agent import build_router as build_agent_router
from django.contrib.admin.views.decorators import staff_member_required
def register_routers(target_api: NinjaAPI) -> None: def register_routers(target_api: NinjaAPI) -> None:
target_api.add_router("/system", build_system_router(), tags=["system"]) target_api.add_router("/system", build_system_router(), tags=["System"])
target_api.add_router("/user", build_accounts_router(), tags=["user"]) target_api.add_router("/user", build_accounts_router(), tags=["Account Context"])
target_api.add_router("/audit", build_audit_router(), tags=["audit"]) target_api.add_router("/audit", build_audit_router(), tags=["Audit Logging"])
target_api.add_router("/servers", build_servers_router(), tags=["servers"]) target_api.add_router("/servers", build_servers_router(), tags=["Servers"])
target_api.add_router("/users", build_users_router(), tags=["users"]) target_api.add_router("/users", build_users_router(), tags=["User Directory"])
target_api.add_router("/keys", build_keys_router(), tags=["keys"]) target_api.add_router("/keys", build_keys_router(), tags=["SSH Keys"])
target_api.add_router("/access-requests", build_access_router(), tags=["access"]) target_api.add_router("/access-requests", build_access_router(), tags=["Access Requests"])
target_api.add_router("/telemetry", build_telemetry_router(), tags=["telemetry"]) target_api.add_router("/telemetry", build_telemetry_router(), tags=["Telemetry"])
target_api.add_router("/agent", build_agent_router(), tags=["agent"]) target_api.add_router("/agent", build_agent_router(), tags=["Agent"])
api = NinjaAPI( def build_api(**kwargs) -> NinjaAPI:
if "csrf" in inspect.signature(NinjaAPI).parameters:
return NinjaAPI(csrf=True, **kwargs)
return NinjaAPI(**kwargs)
api = build_api(
title="Keywarden API", title="Keywarden API",
version="1.0.0", version="1.0.0",
description="Authenticated API for internal app use and external clients.", description="Authenticated API for internal app use and external clients.",
auth=[django_auth, JWTAuth()], auth=[django_auth, JWTAuth()],
csrf=True, # enforce CSRF for session-authenticated unsafe requests docs=Redoc(),
docs_decorator=staff_member_required,
) )
register_routers(api) register_routers(api)
api_v1 = NinjaAPI( api_v1 = build_api(
title="Keywarden API", title="Keywarden API",
version="1.0.0", version="1.0.0",
description="Authenticated API for internal app use and external clients.", description="Authenticated API for internal app use and external clients.",
auth=[django_auth, JWTAuth()], auth=[django_auth, JWTAuth()],
csrf=True,
urls_namespace="api-v1", urls_namespace="api-v1",
docs=Redoc(),
docs_decorator=staff_member_required,
) )
register_routers(api_v1) register_routers(api_v1)

View File

@@ -13,11 +13,15 @@ from pydantic import Field
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.core.rbac import require_authenticated from apps.core.rbac import require_authenticated
from apps.servers.models import Server from apps.servers.models import Server
from apps.access.permissions import sync_server_view_perm
class AccessRequestCreateIn(Schema): class AccessRequestCreateIn(Schema):
server_id: int server_id: int
reason: Optional[str] = None reason: Optional[str] = None
request_shell: bool = False
request_logs: bool = False
request_users: bool = False
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
@@ -32,6 +36,9 @@ class AccessRequestOut(Schema):
server_id: int server_id: int
status: str status: str
reason: str reason: str
request_shell: bool
request_logs: bool
request_users: bool
requested_at: str requested_at: str
decided_at: Optional[str] = None decided_at: Optional[str] = None
expires_at: Optional[str] = None expires_at: Optional[str] = None
@@ -53,6 +60,9 @@ def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
server_id=access_request.server_id, server_id=access_request.server_id,
status=access_request.status, status=access_request.status,
reason=access_request.reason or "", reason=access_request.reason or "",
request_shell=access_request.request_shell,
request_logs=access_request.request_logs,
request_users=access_request.request_users,
requested_at=access_request.requested_at.isoformat(), requested_at=access_request.requested_at.isoformat(),
decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None, decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None,
expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None, expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None,
@@ -77,6 +87,7 @@ def build_router() -> Router:
- If user has global `access.view_accessrequest`, returns all requests. - If user has global `access.view_accessrequest`, returns all requests.
- Otherwise, returns only objects with `access.view_accessrequest` object permission. - Otherwise, returns only objects with `access.view_accessrequest` object permission.
Filters: status, server_id, requester_id (requester_id is honored only with global view). Filters: status, server_id, requester_id (requester_id is honored only with global view).
Rationale: powers the access request queue and auditing views.
""" """
require_authenticated(request) require_authenticated(request)
user = request.user user = request.user
@@ -106,6 +117,9 @@ def build_router() -> Router:
Auth: required. Auth: required.
Permissions: requires global `access.add_accessrequest`. Permissions: requires global `access.add_accessrequest`.
Side effects: grants owner object perms on the new request. Side effects: grants owner object perms on the new request.
Behavior: creates a pending access request; it does not grant access
until approved. Optional expires_at defines the requested access window.
Rationale: this is the entry point for delegating server access.
""" """
require_authenticated(request) require_authenticated(request)
if not request.user.has_perm("access.add_accessrequest"): if not request.user.has_perm("access.add_accessrequest"):
@@ -118,6 +132,9 @@ def build_router() -> Router:
requester=request.user, requester=request.user,
server=server, server=server,
reason=(payload.reason or "").strip(), reason=(payload.reason or "").strip(),
request_shell=payload.request_shell,
request_logs=payload.request_logs,
request_users=payload.request_users,
) )
if payload.expires_at: if payload.expires_at:
access_request.expires_at = payload.expires_at access_request.expires_at = payload.expires_at
@@ -132,6 +149,7 @@ def build_router() -> Router:
Auth: required. Auth: required.
Permissions: requires `access.view_accessrequest` on the object. Permissions: requires `access.view_accessrequest` on the object.
Rationale: used for request detail views and approval workflows.
""" """
require_authenticated(request) require_authenticated(request)
try: try:
@@ -152,6 +170,9 @@ def build_router() -> Router:
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and - Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
update expires_at. update expires_at.
- Non-admin can only set status to cancelled, and only while pending. - Non-admin can only set status to cancelled, and only while pending.
Side effects: updates object permissions for server visibility when
approvals or revocations occur.
Rationale: this is the core approval/denial path for access control.
""" """
require_authenticated(request) require_authenticated(request)
try: try:
@@ -191,25 +212,9 @@ def build_router() -> Router:
else: else:
access_request.decided_by = None access_request.decided_by = None
access_request.save() access_request.save()
sync_server_view_perm(access_request)
return _request_to_out(access_request) return _request_to_out(access_request)
@router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request.
Auth: required.
Permissions: requires `access.delete_accessrequest` on the object.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("access.delete_accessrequest", access_request):
raise HttpError(403, "Forbidden")
access_request.delete()
return 204, None
return router return router

View File

@@ -20,7 +20,14 @@ def build_router() -> Router:
@router.get("/me", response=UserSchema) @router.get("/me", response=UserSchema)
def me(request: HttpRequest): def me(request: HttpRequest):
"""Return the current authenticated user's profile.""" """Return the authenticated user's profile and role context.
Auth: required (session or JWT). Used by the UI to build navigation,
display the user identity, and decide which actions are enabled.
Fields: returns only the minimal identity and privilege flags needed
by the client; no secrets or permissions lists are exposed here.
Rationale: keeps the client-side state aligned with the session user.
"""
require_authenticated(request) require_authenticated(request)
user = request.user user = request.user
return { return {

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
@@ -7,18 +5,29 @@ from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.db import IntegrityError, transaction
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from ninja import Router, Schema from django.views.decorators.csrf import csrf_exempt
from ninja import Body, Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from guardian.shortcuts import get_users_with_perms
from apps.core.rbac import require_perms from apps.core.rbac import require_perms
from apps.access.models import AccessRequest from apps.keys.certificates import get_active_ca
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
from apps.servers.models import AgentCertificateAuthority, EnrollmentToken, Server, hostname_validator from apps.keys.utils import render_system_username
from apps.servers.models import (
AgentCertificateAuthority,
EnrollmentToken,
Server,
ServerAccount,
hostname_validator,
)
from apps.telemetry.models import TelemetryEvent from apps.telemetry.models import TelemetryEvent
@@ -30,11 +39,31 @@ class AuthorizedKeyOut(Schema):
fingerprint: str fingerprint: str
class AccountKeyOut(Schema):
public_key: str
fingerprint: str
class AccountAccessOut(Schema):
user_id: int
username: str
email: str
system_username: str
keys: List[AccountKeyOut] = Field(default_factory=list)
class AccountSyncIn(Schema):
user_id: int
system_username: str
present: bool
class SyncReportIn(Schema): class SyncReportIn(Schema):
applied_count: int = Field(default=0, ge=0) applied_count: int = Field(default=0, ge=0)
revoked_count: int = Field(default=0, ge=0) revoked_count: int = Field(default=0, ge=0)
message: Optional[str] = None message: Optional[str] = None
metadata: dict = Field(default_factory=dict) metadata: dict = Field(default_factory=dict)
accounts: List[AccountSyncIn] = Field(default_factory=list)
class SyncReportOut(Schema): class SyncReportOut(Schema):
@@ -45,6 +74,8 @@ class AgentEnrollIn(Schema):
token: str token: str
csr_pem: str csr_pem: str
host: Optional[str] = None host: Optional[str] = None
ipv4: Optional[str] = None
ipv6: Optional[str] = None
class AgentEnrollOut(Schema): class AgentEnrollOut(Schema):
@@ -74,12 +105,31 @@ class LogIngestOut(Schema):
accepted: int accepted: int
class AgentHeartbeatIn(Schema):
host: Optional[str] = None
ipv4: Optional[str] = None
ipv6: Optional[str] = None
ping_ms: Optional[int] = None
def build_router() -> Router: def build_router() -> Router:
router = Router() router = Router()
@router.post("/enroll", response=AgentEnrollOut, auth=None) @router.post("/enroll", response=AgentEnrollOut, auth=None)
def enroll_agent(request: HttpRequest, payload: AgentEnrollIn): @csrf_exempt
"""Enroll a server agent using a one-time token.""" def enroll_agent(request: HttpRequest, payload: AgentEnrollIn = Body(...)):
"""Enroll a server agent using a one-time enrollment token.
Auth: token only (no session/JWT); mTLS is not yet available until
enrollment completes.
Inputs: enrollment token + CSR from the agent, optional host/IP hints.
Behavior:
- Creates a Server record (agent is the source of truth for host/IP).
- Marks the token as used (single-use).
- Signs the CSR with the active Agent CA and returns client cert + CA.
Rationale: this is the only supported server onboarding flow. If this
endpoint is removed, agents cannot bootstrap mTLS credentials.
"""
token_value = (payload.token or "").strip() token_value = (payload.token or "").strip()
if not token_value: if not token_value:
raise HttpError(422, "Token required") raise HttpError(422, "Token required")
@@ -99,17 +149,27 @@ def build_router() -> Router:
hostname = host hostname = host
except ValidationError: except ValidationError:
hostname = None hostname = None
ipv4 = _normalize_ip(payload.ipv4, 4)
server = Server.objects.create(display_name=display_name, hostname=hostname) ipv6 = _normalize_ip(payload.ipv6, 6)
token.mark_used(server)
token.save(update_fields=["used_at", "server"])
csr = _load_csr((payload.csr_pem or "").strip()) csr = _load_csr((payload.csr_pem or "").strip())
try:
with transaction.atomic():
server = Server.objects.create(
display_name=display_name,
hostname=hostname,
ipv4=ipv4,
ipv6=ipv6,
)
token.mark_used(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)
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.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"])
except IntegrityError:
raise HttpError(409, "Server already enrolled")
return AgentEnrollOut( return AgentEnrollOut(
server_id=str(server.id), server_id=str(server.id),
@@ -119,44 +179,80 @@ def build_router() -> Router:
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut]) @router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int): def authorized_keys(request: HttpRequest, server_id: int):
"""Return authorized public keys for a server (admin or operator).""" """Resolve the effective authorized_keys list for a server.
Auth: required (admin/operator via API).
Permissions: requires view access to servers and keys.
Behavior: uses server object permissions + active SSH keys to produce
the exact key list the agent should deploy to the server.
Rationale: this is the policy enforcement point for per-user access.
"""
require_perms( require_perms(
request, request,
"servers.view_server", "servers.view_server",
"keys.view_sshkey", "keys.view_sshkey",
"access.view_accessrequest",
) )
try: server = _get_server_or_404(server_id)
server = Server.objects.get(id=server_id) users = _resolve_access_users(server)
except Server.DoesNotExist: key_map = _key_map_for_users(users)
raise HttpError(404, "Server not found") output: list[AuthorizedKeyOut] = []
now = timezone.now() for user in users:
access_qs = AccessRequest.objects.select_related("requester").filter( for key in key_map.get(user.id, []):
server=server, output.append(
status=AccessRequest.Status.APPROVED,
)
access_qs = access_qs.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now))
users = [req.requester for req in access_qs if req.requester and req.requester.is_active]
keys = SSHKey.objects.select_related("user").filter(
user__in=users,
is_active=True,
revoked_at__isnull=True,
)
return [
AuthorizedKeyOut( AuthorizedKeyOut(
user_id=key.user_id, user_id=user.id,
username=key.user.username, username=user.username,
email=key.user.email or "", email=user.email or "",
public_key=key.public_key, public_key=key.public_key,
fingerprint=key.fingerprint, fingerprint=key.fingerprint,
) )
for key in keys )
return output
@router.get("/servers/{server_id}/accounts", response=List[AccountAccessOut], auth=None)
def account_access(request: HttpRequest, server_id: int):
"""List accounts that should exist on a server.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: resolves active users with server object perms and their keys.
Rationale: drives agent-side account provisioning.
"""
server = _get_server_or_404(server_id)
users = _resolve_access_users(server)
return [
AccountAccessOut(
user_id=user.id,
username=user.username,
email=user.email or "",
system_username=render_system_username(user.username, user.id),
keys=[],
)
for user in users
] ]
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut) @router.get("/servers/{server_id}/ssh-ca", auth=None)
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn): @csrf_exempt
"""Record an agent sync report for a server (admin or operator).""" def ssh_ca(request: HttpRequest, server_id: int):
require_perms(request, "servers.view_server", "telemetry.add_telemetryevent") """Return the active SSH user CA public key for agents.
Auth: mTLS expected at the edge (no session/JWT).
"""
_ = _get_server_or_404(server_id)
ca = get_active_ca()
if not ca.public_key:
raise HttpError(404, "SSH CA not configured")
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint}
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
@csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""Record an agent sync report for a server.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: stores a telemetry event with counts of applied/revoked keys.
Rationale: provides an audit trail of enforcement actions without
requiring full log ingestion for every sync cycle.
"""
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -173,11 +269,21 @@ def build_router() -> Router:
**(payload.metadata or {}), **(payload.metadata or {}),
}, },
) )
if 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=None)
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn]): @csrf_exempt
"""Accept log batches from agents (mTLS required at the edge).""" def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
"""Accept log batches from agents for audit collection.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: accepts structured log events for later storage and indexing.
Storage: raw logs are persisted separately per-server (SQLite shards),
not in the primary Postgres database.
Rationale: this is the ingestion pipe for security audit logging.
"""
try: try:
Server.objects.get(id=server_id) Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -185,9 +291,107 @@ def build_router() -> Router:
# TODO: enqueue to Valkey and persist to SQLite slices. # TODO: enqueue to Valkey and persist to SQLite slices.
return LogIngestOut(status="accepted", accepted=len(payload)) return LogIngestOut(status="accepted", accepted=len(payload))
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=None)
@csrf_exempt
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
"""Update server host metadata (hostname/IPs) reported by the agent.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
Conflict: unique constraints are enforced; conflicts return 409.
Rationale: keeps the server inventory accurate without manual edits.
"""
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
updates: dict[str, str | int | datetime] = {}
host = (payload.host or "").strip()[:253]
if host:
try:
hostname_validator(host)
if server.hostname != host:
updates["hostname"] = host
except ValidationError:
pass
ipv4 = _normalize_ip(payload.ipv4, 4)
if ipv4 and server.ipv4 != ipv4:
updates["ipv4"] = ipv4
ipv6 = _normalize_ip(payload.ipv6, 6)
if ipv6 and server.ipv6 != ipv6:
updates["ipv6"] = ipv6
now = timezone.now()
updates["last_heartbeat_at"] = now
if payload.ping_ms is not None:
updates["last_ping_ms"] = max(0, int(payload.ping_ms))
if updates:
for field, value in updates.items():
setattr(server, field, value)
try:
server.save(update_fields=list(updates.keys()))
except IntegrityError:
raise HttpError(409, "Server address already in use")
return SyncReportOut(status="ok")
return router return router
def _get_server_or_404(server_id: int) -> Server:
try:
return Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
def _resolve_access_users(server: Server) -> list:
users = list(
get_users_with_perms(
server,
only_with_perms_in=["view_server"],
with_group_users=True,
with_superusers=False,
)
)
active = [user for user in users if getattr(user, "is_active", False)]
return sorted(active, key=lambda user: (user.username or "", user.id))
def _key_map_for_users(users: list) -> dict[int, list[SSHKey]]:
if not users:
return {}
keys = SSHKey.objects.select_related("user").filter(
user__in=users,
is_active=True,
revoked_at__isnull=True,
)
key_map: dict[int, list[SSHKey]] = {}
for key in keys:
key_map.setdefault(key.user_id, []).append(key)
return key_map
def _update_server_accounts(server: Server, accounts: list[AccountSyncIn]) -> None:
user_ids = {account.user_id for account in accounts}
if not user_ids:
return
User = get_user_model()
users = {user.id: user for user in User.objects.filter(id__in=user_ids)}
now = timezone.now()
for account in accounts:
user = users.get(account.user_id)
if not user:
continue
ServerAccount.objects.update_or_create(
server=server,
user=user,
defaults={
"system_username": account.system_username,
"is_present": account.present,
"last_synced_at": now,
},
)
def _load_agent_ca() -> tuple[x509.Certificate, object, str]: def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
ca = ( ca = (
AgentCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True) AgentCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
@@ -246,4 +450,17 @@ def _issue_client_cert(
return cert_pem, ca_pem, fingerprint, serial return cert_pem, ca_pem, fingerprint, serial
def _normalize_ip(value: Optional[str], version: int) -> Optional[str]:
if not value:
return None
try:
if version == 4:
validate_ipv4_address(value)
else:
validate_ipv6_address(value)
except ValidationError:
return None
return value
router = build_router() router = build_router()

View File

@@ -15,7 +15,13 @@ class AuditEventTypeSchema(Schema):
key: str key: str
title: str title: str
description: str | None = None description: str | None = None
kind: str
default_severity: str default_severity: str
endpoints: list[str]
ip_whitelist_enabled: bool
ip_whitelist: list[str]
ip_blacklist_enabled: bool
ip_blacklist: list[str]
class AuditLogSchema(Schema): class AuditLogSchema(Schema):
@@ -47,7 +53,14 @@ def build_router() -> Router:
@router.get("/event-types", response=List[AuditEventTypeSchema]) @router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest): def list_event_types(request: HttpRequest):
"""List audit event types and their default severity.""" """List audit event types used by the platform audit log.
Auth: required.
Permissions: requires global `audit.view_auditeventtype`.
Behavior: returns the canonical event taxonomy (key, title, severity).
Rationale: the admin UI and audit filters use this to map log entries
to human-readable categories and severity defaults.
"""
require_perms(request, "audit.view_auditeventtype") require_perms(request, "audit.view_auditeventtype")
qs: QuerySet[AuditEventType] = AuditEventType.objects.all() qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [ return [
@@ -56,14 +69,29 @@ def build_router() -> Router:
"key": et.key, "key": et.key,
"title": et.title, "title": et.title,
"description": et.description or "", "description": et.description or "",
"kind": et.kind,
"default_severity": et.default_severity, "default_severity": et.default_severity,
"endpoints": list(et.endpoints or []),
"ip_whitelist_enabled": bool(et.ip_whitelist_enabled),
"ip_whitelist": list(et.ip_whitelist or []),
"ip_blacklist_enabled": bool(et.ip_blacklist_enabled),
"ip_blacklist": list(et.ip_blacklist or []),
} }
for et in qs for et in qs
] ]
@router.get("/logs", response=List[AuditLogSchema]) @router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)): def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination.""" """List application audit log entries with filters and pagination.
Auth: required.
Permissions: requires global `audit.view_auditlog`.
Filters: severity, actor_id, event_type_key, source.
Pagination: limit + offset.
Scope: this is the Keywarden app audit trail (who changed what), not
the server OS log ingestion stream stored by the agent.
Rationale: used by the audit UI and for administrative forensics.
"""
require_perms(request, "audit.view_auditlog") require_perms(request, "audit.view_auditlog")
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all() qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity: if filters.severity:

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import hashlib
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError, transaction
from django.http import HttpRequest from django.http import HttpRequest, HttpResponse
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from ninja import Query, Router, Schema from ninja import Query, Router, Schema
@@ -13,7 +15,8 @@ from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from apps.core.rbac import require_authenticated from apps.core.rbac import require_authenticated
from apps.keys.models import SSHKey from apps.keys.certificates import issue_certificate_for_key, revoke_certificate_for_key
from apps.keys.models import SSHCertificate, SSHKey
class KeyCreateIn(Schema): class KeyCreateIn(Schema):
@@ -39,6 +42,14 @@ class KeyOut(Schema):
revoked_at: Optional[str] = None revoked_at: Optional[str] = None
class CertificateOut(Schema):
key_id: int
serial: int
valid_after: str
valid_before: str
principals: List[str]
class KeysQuery(Schema): class KeysQuery(Schema):
limit: int = Field(default=50, ge=1, le=200) limit: int = Field(default=50, ge=1, le=200)
offset: int = Field(default=0, ge=0) offset: int = Field(default=0, ge=0)
@@ -59,6 +70,19 @@ def _key_to_out(key: SSHKey) -> KeyOut:
) )
def _ensure_certificate(key: SSHKey, request_user) -> SSHCertificate:
if not key.is_active:
raise HttpError(409, "Key is revoked")
now = timezone.now()
try:
cert = key.certificate
except SSHCertificate.DoesNotExist:
return issue_certificate_for_key(key, created_by=request_user)
if not cert.is_active or cert.valid_before <= now:
return issue_certificate_for_key(key, created_by=request_user)
return cert
def _has_global_perm(request: HttpRequest, perm: str) -> bool: def _has_global_perm(request: HttpRequest, perm: str) -> bool:
user = request.user user = request.user
return bool(user and user.has_perm(perm)) return bool(user and user.has_perm(perm))
@@ -76,6 +100,7 @@ def build_router() -> Router:
- If user has global `keys.view_sshkey`, returns all keys. - If user has global `keys.view_sshkey`, returns all keys.
- Otherwise, returns only objects with `keys.view_sshkey` object permission. - Otherwise, returns only objects with `keys.view_sshkey` object permission.
Filter: user_id (honored only with global view). Filter: user_id (honored only with global view).
Rationale: powers the key inventory UI and lets admins audit key usage.
""" """
require_authenticated(request) require_authenticated(request)
user = request.user user = request.user
@@ -104,6 +129,7 @@ def build_router() -> Router:
- Default owner is the current user. - Default owner is the current user.
- If caller has global `keys.add_sshkey` and `keys.view_sshkey`, they may specify user_id. - If caller has global `keys.add_sshkey` and `keys.view_sshkey`, they may specify user_id.
Side effects: grants owner object perms on the new key. Side effects: grants owner object perms on the new key.
Rationale: keys are the core authorization material synced to servers.
""" """
require_authenticated(request) require_authenticated(request)
if not request.user.has_perm("keys.add_sshkey"): if not request.user.has_perm("keys.add_sshkey"):
@@ -129,9 +155,13 @@ def build_router() -> Router:
except ValidationError as exc: except ValidationError as exc:
raise HttpError(422, {"public_key": [str(exc)]}) raise HttpError(422, {"public_key": [str(exc)]})
try: try:
with transaction.atomic():
key.save() key.save()
issue_certificate_for_key(key, created_by=request.user)
except IntegrityError: except IntegrityError:
raise HttpError(422, {"public_key": ["Key already exists."]}) raise HttpError(422, {"public_key": ["Key already exists."]})
except Exception as exc:
raise HttpError(500, {"detail": f"Certificate issuance failed: {exc}"})
return _key_to_out(key) return _key_to_out(key)
@router.get("/{key_id}", response=KeyOut) @router.get("/{key_id}", response=KeyOut)
@@ -140,6 +170,7 @@ def build_router() -> Router:
Auth: required. Auth: required.
Permissions: requires `keys.view_sshkey` on the object. Permissions: requires `keys.view_sshkey` on the object.
Rationale: used by key detail views and server access debugging.
""" """
require_authenticated(request) require_authenticated(request)
try: try:
@@ -150,12 +181,71 @@ def build_router() -> Router:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
return _key_to_out(key) return _key_to_out(key)
@router.post("/{key_id}/certificate", response=CertificateOut)
def issue_certificate(request: HttpRequest, key_id: int):
"""Issue or re-issue an SSH certificate for a key.
Auth: required.
Permissions: requires `keys.view_sshkey` on the object.
Rationale: allows users to download a fresh certificate as needed.
"""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
cert = issue_certificate_for_key(key, created_by=request.user)
return CertificateOut(
key_id=key.id,
serial=cert.serial,
valid_after=cert.valid_after.isoformat(),
valid_before=cert.valid_before.isoformat(),
principals=list(cert.principals or []),
)
@router.get("/{key_id}/certificate")
def download_certificate(request: HttpRequest, key_id: int):
"""Download the SSH certificate for a key."""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
cert = _ensure_certificate(key, request.user)
filename = f"keywarden-{key.user_id}-{key.id}-cert.pub"
response = HttpResponse(cert.certificate, content_type="text/plain")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@router.get("/{key_id}/certificate.sha256")
def download_certificate_hash(request: HttpRequest, key_id: int):
"""Download the SSH certificate hash for a key."""
require_authenticated(request)
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden")
cert = _ensure_certificate(key, request.user)
filename = f"keywarden-{key.user_id}-{key.id}-cert.pub"
digest = hashlib.sha256(cert.certificate.encode("utf-8")).hexdigest()
payload = f"{digest} {filename}\n"
response = HttpResponse(payload, content_type="text/plain")
response["Content-Disposition"] = f'attachment; filename="{filename}.sha256"'
return response
@router.patch("/{key_id}", response=KeyOut) @router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn): def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state. """Update key name or active state.
Auth: required. Auth: required.
Permissions: requires `keys.change_sshkey` on the object. Permissions: requires `keys.change_sshkey` on the object.
Rationale: allows key rotation and revocation without deletion.
""" """
require_authenticated(request) require_authenticated(request)
try: try:
@@ -175,8 +265,13 @@ def build_router() -> Router:
key.is_active = payload.is_active key.is_active = payload.is_active
if payload.is_active: if payload.is_active:
key.revoked_at = None key.revoked_at = None
try:
issue_certificate_for_key(key, created_by=request.user)
except Exception as exc:
raise HttpError(500, {"detail": f"Certificate issuance failed: {exc}"})
else: else:
key.revoked_at = timezone.now() key.revoked_at = timezone.now()
revoke_certificate_for_key(key)
key.save() key.save()
return _key_to_out(key) return _key_to_out(key)
@@ -187,6 +282,7 @@ def build_router() -> Router:
Auth: required. Auth: required.
Permissions: requires `keys.delete_sshkey` on the object. Permissions: requires `keys.delete_sshkey` on the object.
Behavior: sets is_active false and revoked_at if key is active. Behavior: sets is_active false and revoked_at if key is active.
Rationale: removes key access while preserving auditability.
""" """
require_authenticated(request) require_authenticated(request)
try: try:
@@ -199,6 +295,7 @@ def build_router() -> Router:
key.is_active = False key.is_active = False
key.revoked_at = timezone.now() key.revoked_at = timezone.now()
key.save(update_fields=["is_active", "revoked_at"]) key.save(update_fields=["is_active", "revoked_at"])
revoke_certificate_for_key(key)
return 204, None return 204, None
return router return router

View File

@@ -2,12 +2,11 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from django.db import IntegrityError
from django.http import HttpRequest from django.http import HttpRequest
from ninja import File, Form, Router, Schema from ninja import Router, Schema
from ninja.files import UploadedFile
from ninja.errors import HttpError from ninja.errors import HttpError
from apps.core.rbac import require_perms from guardian.shortcuts import get_objects_for_user, get_perms
from apps.core.rbac import require_authenticated, require_perms
from apps.servers.models import Server from apps.servers.models import Server
@@ -21,18 +20,8 @@ class ServerOut(Schema):
initial: str initial: str
class ServerCreate(Schema):
display_name: str
hostname: Optional[str] = None
ipv4: Optional[str] = None
ipv6: Optional[str] = None
class ServerUpdate(Schema): class ServerUpdate(Schema):
display_name: Optional[str] = None display_name: Optional[str] = None
hostname: Optional[str] = None
ipv4: Optional[str] = None
ipv6: Optional[str] = None
def build_router() -> Router: def build_router() -> Router:
@@ -40,9 +29,20 @@ def build_router() -> Router:
@router.get("/", response=List[ServerOut]) @router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest): def list_servers(request: HttpRequest):
"""List servers visible to authenticated users.""" """List servers the caller can view.
require_perms(request, "servers.view_server")
servers = Server.objects.all() Auth: required.
Permissions: requires `servers.view_server` via object permissions.
Behavior: returns only servers the user can see via object perms.
Rationale: drives the server dashboard and access-aware navigation.
"""
require_authenticated(request)
servers = get_objects_for_user(
request.user,
"servers.view_server",
klass=Server,
accept_global_perms=False,
)
return [ return [
{ {
"id": s.id, "id": s.id,
@@ -58,12 +58,20 @@ def build_router() -> Router:
@router.get("/{server_id}", response=ServerOut) @router.get("/{server_id}", response=ServerOut)
def get_server(request: HttpRequest, server_id: int): def get_server(request: HttpRequest, server_id: int):
"""Get server details by id.""" """Get a server record by id.
require_perms(request, "servers.view_server")
Auth: required.
Permissions: requires `servers.view_server` via object permissions.
Rationale: used by server detail views and API clients inspecting
server metadata (hostname/IPs populated by the agent).
"""
require_authenticated(request)
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
if "view_server" not in get_perms(request.user, server):
raise HttpError(403, "Forbidden")
return { return {
"id": server.id, "id": server.id,
"display_name": server.display_name, "display_name": server.display_name,
@@ -74,55 +82,28 @@ def build_router() -> Router:
"initial": server.initial, "initial": server.initial,
} }
@router.post("/", response=ServerOut)
def create_server_json(request: HttpRequest, payload: ServerCreate):
"""Create a server using JSON payload (admin only)."""
require_perms(request, "servers.add_server")
raise HttpError(403, "Servers are created via agent enrollment tokens.")
@router.post("/upload", response=ServerOut)
def create_server_multipart(
request: HttpRequest,
display_name: str = Form(...),
hostname: Optional[str] = Form(None),
ipv4: Optional[str] = Form(None),
ipv6: Optional[str] = Form(None),
image: Optional[UploadedFile] = File(None),
):
"""Create a server with optional image upload (admin only)."""
require_perms(request, "servers.add_server")
raise HttpError(403, "Servers are created via agent enrollment tokens.")
@router.patch("/{server_id}", response=ServerOut) @router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate): def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
"""Update server fields (admin only).""" """Update the server display name (admin only).
Auth: required.
Permissions: requires `servers.change_server`.
Behavior: only display_name is editable via API; host/IP data is owned
by the agent heartbeat to avoid conflicting sources of truth.
Rationale: allows human-friendly naming without bypassing enrollment.
"""
require_perms(request, "servers.change_server") require_perms(request, "servers.change_server")
if ( if payload.display_name is None:
payload.display_name is None
and payload.hostname is None
and payload.ipv4 is None
and payload.ipv6 is None
):
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
if payload.display_name is not None:
display_name = payload.display_name.strip() display_name = payload.display_name.strip()
if not display_name: if not display_name:
raise HttpError(422, {"display_name": ["Display name cannot be empty."]}) raise HttpError(422, {"display_name": ["Display name cannot be empty."]})
server.display_name = display_name server.display_name = display_name
if payload.hostname is not None: server.save(update_fields=["display_name"])
server.hostname = (payload.hostname or "").strip() or None
if payload.ipv4 is not None:
server.ipv4 = (payload.ipv4 or "").strip() or None
if payload.ipv6 is not None:
server.ipv6 = (payload.ipv6 or "").strip() or None
try:
server.save()
except IntegrityError:
raise HttpError(422, {"detail": "Unique constraint violated."})
return { return {
"id": server.id, "id": server.id,
"display_name": server.display_name, "display_name": server.display_name,
@@ -133,17 +114,6 @@ def build_router() -> Router:
"initial": server.initial, "initial": server.initial,
} }
@router.delete("/{server_id}", response={204: None})
def delete_server(request: HttpRequest, server_id: int):
"""Delete a server by id (admin only)."""
require_perms(request, "servers.delete_server")
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Not Found")
server.delete()
return 204, None
return router return router

View File

@@ -14,7 +14,14 @@ def build_router() -> Router:
@router.get("/health", response=HealthResponse) @router.get("/health", response=HealthResponse)
def health(request) -> HealthResponse: def health(request) -> HealthResponse:
"""Health check endpoint for service monitoring.""" """Return application liveness for internal monitoring.
Auth: required (session or JWT). This is intentionally protected to avoid
exposing internal status to unauthenticated callers.
Behavior: returns a static {"status": "ok"} if the app stack is reachable.
Rationale: used by uptime checks and deployments to confirm the API
process is running and can authenticate requests.
"""
require_authenticated(request) require_authenticated(request)
return {"status": "ok"} return {"status": "ok"}

View File

@@ -71,7 +71,13 @@ def build_router() -> Router:
@router.get("/", response=List[TelemetryOut]) @router.get("/", response=List[TelemetryOut])
def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)): def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)):
"""List telemetry events with filters (admin or operator).""" """List telemetry events emitted by the platform and agents.
Auth: required.
Permissions: requires `telemetry.view_telemetryevent`.
Filters: event_type, server_id, user_id, success.
Rationale: supports operational dashboards and audit-style timelines.
"""
require_perms(request, "telemetry.view_telemetryevent") require_perms(request, "telemetry.view_telemetryevent")
qs = TelemetryEvent.objects.order_by("-created_at") qs = TelemetryEvent.objects.order_by("-created_at")
if filters.event_type: if filters.event_type:
@@ -87,7 +93,14 @@ def build_router() -> Router:
@router.post("/", response=TelemetryOut) @router.post("/", response=TelemetryOut)
def create_event(request: HttpRequest, payload: TelemetryCreateIn): def create_event(request: HttpRequest, payload: TelemetryCreateIn):
"""Create a telemetry event entry (admin or operator).""" """Create a telemetry event entry.
Auth: required.
Permissions: requires `telemetry.add_telemetryevent`.
Behavior: validates server/user references and normalizes source.
Rationale: used by internal automation; if external clients are not
expected to emit telemetry, this endpoint can be restricted further.
"""
require_perms(request, "telemetry.add_telemetryevent") require_perms(request, "telemetry.add_telemetryevent")
server = None server = None
if payload.server_id: if payload.server_id:
@@ -115,7 +128,12 @@ def build_router() -> Router:
@router.get("/summary", response=TelemetrySummaryOut) @router.get("/summary", response=TelemetrySummaryOut)
def summary(request: HttpRequest): def summary(request: HttpRequest):
"""Return a high-level telemetry summary (admin or operator).""" """Return a high-level success/failure summary.
Auth: required.
Permissions: requires `telemetry.view_telemetryevent`.
Rationale: feeds dashboard widgets without pulling full event lists.
"""
require_perms(request, "telemetry.view_telemetryevent") require_perms(request, "telemetry.view_telemetryevent")
totals = TelemetryEvent.objects.aggregate( totals = TelemetryEvent.objects.aggregate(
total=Count("id"), total=Count("id"),

View File

@@ -53,7 +53,15 @@ def build_router() -> Router:
@router.post("/", response=UserDetailOut) @router.post("/", response=UserDetailOut)
def create_user(request: HttpRequest, payload: UserCreateIn): def create_user(request: HttpRequest, payload: UserCreateIn):
"""Create a user with role and password (admin or operator).""" """Create a platform user and assign a Keywarden role.
Auth: required.
Permissions: requires `auth.add_user` (admin/operator).
Behavior: uses email as username, hashes the password, and assigns a
role which maps to Keywarden group permissions.
Rationale: enables automation and external admin workflows; mirrors
the admin UI user creation flow.
"""
require_perms(request, "auth.add_user") require_perms(request, "auth.add_user")
User = get_user_model() User = get_user_model()
email = payload.email.strip().lower() email = payload.email.strip().lower()
@@ -79,7 +87,13 @@ def build_router() -> Router:
@router.get("/", response=List[UserListOut]) @router.get("/", response=List[UserListOut])
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)): def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
"""List users with pagination (admin or operator).""" """List users for administrative visibility and access management.
Auth: required.
Permissions: requires `auth.view_user`.
Pagination: limit + offset.
Rationale: used by admin UI and automation to audit user access.
"""
require_perms(request, "auth.view_user") require_perms(request, "auth.view_user")
User = get_user_model() User = get_user_model()
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit] qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
@@ -95,7 +109,12 @@ def build_router() -> Router:
@router.get("/{user_id}", response=UserDetailOut) @router.get("/{user_id}", response=UserDetailOut)
def get_user(request: HttpRequest, user_id: int): def get_user(request: HttpRequest, user_id: int):
"""Get user details by id (admin or operator).""" """Fetch a single user record for inspection.
Auth: required.
Permissions: requires `auth.view_user`.
Rationale: used by admin detail views and automation scripts.
"""
require_perms(request, "auth.view_user") require_perms(request, "auth.view_user")
User = get_user_model() User = get_user_model()
try: try:
@@ -111,7 +130,13 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut) @router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn): def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user fields such as role, email, or status (admin only).""" """Update user identity, role, password, or activation state.
Auth: required.
Permissions: requires `auth.change_user` (admin).
Side effects: role changes update Keywarden role/group mappings.
Rationale: required for role delegation and account lifecycle control.
"""
require_perms(request, "auth.change_user") require_perms(request, "auth.change_user")
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None: if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
@@ -143,18 +168,6 @@ def build_router() -> Router:
"is_active": user.is_active, "is_active": user.is_active,
} }
@router.delete("/{user_id}", response={204: None})
def delete_user(request: HttpRequest, user_id: int):
"""Delete a user by id (admin only)."""
require_perms(request, "auth.delete_user")
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise HttpError(404, "Not Found")
user.delete()
return 204, None
return router return router

View File

@@ -0,0 +1,19 @@
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "keywarden.settings.dev")
django_app = get_asgi_application()
from .routing import websocket_urlpatterns # noqa: E402
application = ProtocolTypeRouter(
{
"http": django_app,
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
}
)

9
app/keywarden/celery.py Normal file
View File

@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "keywarden.settings.dev")
app = Celery("keywarden")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

7
app/keywarden/routing.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import re_path
from apps.servers.consumers import ShellConsumer
websocket_urlpatterns = [
re_path(r"^ws/servers/(?P<server_id>\d+)/shell/$", ShellConsumer.as_asgi()),
]

View File

@@ -3,8 +3,10 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Load environment overrides early so settings can reference them.
load_dotenv() load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -19,6 +21,7 @@ CSRF_TRUSTED_ORIGINS = [
if origin.strip() if origin.strip()
] ]
# Default to secure cookies and respect TLS termination headers.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
@@ -33,6 +36,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"channels",
"guardian", "guardian",
"rest_framework", "rest_framework",
"apps.audit", "apps.audit",
@@ -46,7 +50,8 @@ INSTALLED_APPS = [
"ninja", # Django Ninja API "ninja", # Django Ninja API
"mozilla_django_oidc", # OIDC Client "mozilla_django_oidc", # OIDC Client
"tailwind", "tailwind",
"theme" "theme",
"keywarden"
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -91,10 +96,35 @@ CACHES = {
} }
} }
# In-memory channel layer keeps local development simple.
CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default" SESSION_CACHE_ALIAS = "default"
# Certificate validity defaults; can be tightened via env vars.
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_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES", "15"))
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
)
KEYWARDEN_HEARTBEAT_STALE_SECONDS = int(os.getenv("KEYWARDEN_HEARTBEAT_STALE_SECONDS", "120"))
CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
CELERY_BEAT_SCHEDULE = {
"expire-access-requests": {
"task": "apps.access.tasks.expire_access_requests",
"schedule": 60.0,
},
}
PASSWORD_HASHERS = [ PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher", "django.contrib.auth.hashers.Argon2PasswordHasher",
@@ -128,14 +158,32 @@ TEMPLATES = [
# AUTHENTICATION_BACKENDS is configured dynamically below based on KEYWARDEN_AUTH_MODE # AUTHENTICATION_BACKENDS is configured dynamically below based on KEYWARDEN_AUTH_MODE
UNFOLD = { UNFOLD = {
"SITE_TITLE": "Keywarden Admin", "SITE_ICON": lambda request: static("branding/keywarden-favicon.svg"),
"SITE_LOGO": lambda request: static("branding/keywarden-favicon.svg"),
"SITE_TITLE": "Admin - Keywarden",
"SITE_HEADER": "Keywarden", "SITE_HEADER": "Keywarden",
"SITE_FAVICONS": [
{
"rel": "icon",
"sizes": "32x32",
"type": "image/svg+xml",
"href": lambda request: static("branding/keywarden-favicon.svg"),
},
],
"SITE_DROPDOWN": [
{
"icon": "diamond",
"title": _("Gitea"),
"link": "https://git.ntbx.io/boris/keywarden",
},
],
"SHOW_HISTORY": True, "SHOW_HISTORY": True,
"SITE_URL": "/", "SITE_URL": "/",
"LOGIN_REDIRECT_URL": "/admin/", "LOGIN_REDIRECT_URL": "/admin/",
"ENVIRONMENT": "Keywarden", "ENVIRONMENT": "Keywarden",
"ENVIRONMENT_COLOR": "#7C3AED", "ENVIRONMENT_COLOR": "#7C3AED",
"SHOW_VIEW_ON_SITE": True, "SHOW_VIEW_ON_SITE": True,
# Force a consistent admin theme; disables theme switching.
"THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher "THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher
"SIDEBAR": { "SIDEBAR": {
"show_search": True, "show_search": True,
@@ -157,42 +205,39 @@ UNFOLD = {
"STYLES": [ "STYLES": [
"/static/unfold/css/styles.css", "/static/unfold/css/styles.css",
"/static/unfold/css/simplebar.css", "/static/unfold/css/simplebar.css",
(lambda request: "/static/unfold/css/keywarden.css"), #(lambda request: "/static/unfold/css/keywarden.css"),
],
"SCRIPTS": [
"/static/unfold/js/simplebar.js",
],
"TABS": [
{
"models": [
"auth.User",
],
"items": [
{
"title": _("Logs"),
"link": reverse_lazy("admin:audit_auditlog_changelist"),
"attrs": {"hx-boost": "true"},
},
{
"title": _("Event Types"),
"link": reverse_lazy("admin:audit_auditeventtype_changelist"),
"attrs": {"hx-boost": "true"},
},
],
},
{
"models": [
"servers.Server",
],
"items": [
{
"title": _("Servers"),
"link": reverse_lazy("admin:servers_server_changelist"),
"attrs": {"hx-boost": "true"},
},
],
},
], ],
# "TABS": [
# {
# "models": [
# "auth.User",
# ],
# "items": [
# {
# "title": _("Logs"),
# "link": reverse_lazy("admin:audit_auditlog_changelist"),
# "attrs": {"hx-boost": "true"},
# },
# {
# "title": _("Event Types"),
# "link": reverse_lazy("admin:audit_auditeventtype_changelist"),
# "attrs": {"hx-boost": "true"},
# },
# ],
# },
# {
# "models": [
# "servers.Server",
# ],
# "items": [
# {
# "title": _("Servers"),
# "link": reverse_lazy("admin:servers_server_changelist"),
# "attrs": {"hx-boost": "true"},
# },
# ],
# },
# ],
} }
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR/"media" MEDIA_ROOT = BASE_DIR/"media"
@@ -211,6 +256,7 @@ if AUTH_MODE not in {"native", "oidc", "hybrid"}:
KEYWARDEN_AUTH_MODE = AUTH_MODE KEYWARDEN_AUTH_MODE = AUTH_MODE
if AUTH_MODE == "oidc": if AUTH_MODE == "oidc":
# OIDC-only: enforce identity provider logins.
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend", "guardian.backends.ObjectPermissionBackend",
@@ -226,10 +272,11 @@ else:
] ]
LOGIN_URL = "/accounts/login/" LOGIN_URL = "/accounts/login/"
LOGOUT_URL = "/oidc/logout/" LOGOUT_URL = "/oidc/logout/"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/servers/"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
ANONYMOUS_USER_NAME = None ANONYMOUS_USER_NAME = None
def permission_callback(request): def permission_callback(request):
# Guard admin-side model changes behind a single permission check.
return request.user.has_perm("keywarden.change_model") return request.user.has_perm("keywarden.change_model")

View File

@@ -8,10 +8,14 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("oidc/", include("mozilla_django_oidc.urls")), path("oidc/", include("mozilla_django_oidc.urls")),
path("accounts/", include("apps.accounts.urls")), path("accounts/", include("apps.accounts.urls")),
path("servers/", include("apps.servers.urls")),
# API # API
path("api/", ninja_api.urls), path("api/", ninja_api.urls),
path("api/v1/", ninja_api_v1.urls), path("api/v1/", ninja_api_v1.urls),
path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"), path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"),
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"), path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
path("", RedirectView.as_view(pattern_name="accounts:login", permanent=False)), path("", RedirectView.as_view(pattern_name="servers:dashboard", permanent=False)),
] ]
handler404 = "apps.core.views.disguised_not_found"

4
app/scripts/daphne.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
exec daphne -b 0.0.0.0 -p 8001 keywarden.asgi:application

View File

@@ -34,9 +34,16 @@ html[data-theme="light"],
--error-fg: #ba2121; --error-fg: #ba2121;
--message-debug-bg: #efefef;
--message-debug-icon: url(../img/icon-debug.svg);
--message-info-bg: #ccefff;
--message-info-icon: url(../img/icon-info.svg);
--message-success-bg: #dfd; --message-success-bg: #dfd;
--message-success-icon: url(../img/icon-yes.svg);
--message-warning-bg: #ffc; --message-warning-bg: #ffc;
--message-warning-icon: url(../img/icon-alert.svg);
--message-error-bg: #ffefef; --message-error-bg: #ffefef;
--message-error-icon: url(../img/icon-no.svg);
--darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */
--selected-bg: #e4e4e4; /* E.g. selected table cells */ --selected-bg: #e4e4e4; /* E.g. selected table cells */
@@ -118,6 +125,16 @@ a:focus {
text-decoration: underline; text-decoration: underline;
} }
a:not(
[role="button"],
#header a,
#nav-sidebar a,
#content-main.app-list a,
.object-tools a
) {
text-decoration: underline;
}
a img { a img {
border: none; border: none;
} }
@@ -226,10 +243,10 @@ details summary {
blockquote { blockquote {
font-size: 0.6875rem; font-size: 0.6875rem;
color: #777; color: var(--body-quiet-color);
margin-left: 2px; margin-left: 2px;
padding-left: 10px; padding-left: 10px;
border-left: 5px solid #ddd; border-left: 5px solid currentColor;
} }
code, pre { code, pre {
@@ -628,20 +645,44 @@ ul.messagelist li {
font-size: 0.8125rem; font-size: 0.8125rem;
padding: 10px 10px 10px 65px; padding: 10px 10px 10px 65px;
margin: 0 0 10px 0; margin: 0 0 10px 0;
background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat;
background-size: 16px auto;
color: var(--body-fg); color: var(--body-fg);
word-break: break-word; word-break: break-word;
background-color: var(--message-info-bg);
background-image: var(--message-info-icon);
background-position: 40px 12px;
background-repeat: no-repeat;
background-size: 16px auto;
}
ul.messagelist li.debug {
background-color: var(--message-debug-bg);
background-image: var(--message-debug-icon);
}
ul.messagelist li.info {
background-color: var(--message-info-bg);
background-image: var(--message-info-icon);
}
ul.messagelist li.success {
background-color: var(--message-success-bg);
background-image: var(--message-success-icon);
} }
ul.messagelist li.warning { ul.messagelist li.warning {
background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; background-color: var(--message-warning-bg);
background-size: 14px auto; background-image: var(--message-warning-icon);
} }
ul.messagelist li.error { ul.messagelist li.error {
background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; background-color: var(--message-error-bg);
background-size: 16px auto; background-image: var(--message-error-icon);
}
@media (forced-colors: active) {
ul.messagelist li {
border: 1px solid;
}
} }
.errornote { .errornote {
@@ -768,19 +809,19 @@ a.deletelink:focus, a.deletelink:hover {
/* OBJECT TOOLS */ /* OBJECT TOOLS */
.object-tools { .object-tools {
font-size: 0.625rem; padding: 0;
font-weight: bold; overflow: hidden;
padding-left: 0; text-align: right;
float: right; margin: 0 0 15px;
position: relative;
margin-top: -48px;
} }
.object-tools li { .object-tools li {
display: block; display: inline-block;
float: left; height: auto;
margin-left: 5px; }
height: 1rem;
.object-tools li + li {
margin-left: 15px;
} }
.object-tools a { .object-tools a {
@@ -1120,39 +1161,40 @@ a.deletelink:focus, a.deletelink:hover {
line-height: 22px; line-height: 22px;
margin: 0; margin: 0;
border-top: 1px solid var(--hairline-color); border-top: 1px solid var(--hairline-color);
width: 100%; box-sizing: border-box;
} }
.paginator a:link, .paginator a:visited { .paginator ul {
margin: 0;
margin-right: 6px;
}
.paginator ul li {
display: inline-block;
line-height: 22px;
padding: 0;
}
.paginator a {
display: inline-block;
padding: 2px 6px; padding: 2px 6px;
}
.paginator a:not(.showall) {
background: var(--button-bg); background: var(--button-bg);
text-decoration: none; text-decoration: none;
color: var(--button-fg); color: var(--button-fg);
} }
.paginator a.showall { .paginator a[aria-current="page"] {
border: none; color: var(--body-quiet-color);
background: none; background: transparent;
color: var(--link-fg);
}
.paginator a.showall:focus, .paginator a.showall:hover {
background: none;
color: var(--link-hover-color);
}
.paginator .end {
margin-right: 6px;
}
.paginator .this-page {
padding: 2px 6px;
font-weight: bold; font-weight: bold;
font-size: 0.8125rem; cursor: default;
vertical-align: top;
} }
.paginator a:focus, .paginator a:hover { .paginator a:not([aria-current="page"], .showall):focus,
.paginator a:not([aria-current="page"], .showall):hover {
color: white; color: white;
background: var(--link-hover-color); background: var(--link-hover-color);
} }

View File

@@ -1,14 +1,22 @@
/* CHANGELISTS */ /* CHANGELISTS */
#changelist { #changelist .changelist-form-container {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; width: 100%;
} }
#changelist .changelist-form-container { #changelist .changelist-form-container > div {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; }
#changelist .changelist-form-container:not(:has(#changelist-filter)) > div {
width: 100%;
}
#changelist .changelist-form-container:has(#changelist-filter) > div {
max-width: calc(100% - 270px);
} }
#changelist table { #changelist table {
@@ -25,8 +33,8 @@
min-height: 400px; min-height: 400px;
} }
.change-list .filtered .results, .change-list .filtered .paginator, .change-list .filtered .results, .filtered #toolbar,
.filtered #toolbar, .filtered div.xfull { .filtered div.xfull {
width: auto; width: auto;
} }
@@ -43,11 +51,31 @@
border-bottom: 1px solid var(--hairline-color); border-bottom: 1px solid var(--hairline-color);
} }
#changelist .changelist-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border-top: 1px solid var(--hairline-color);
border-bottom: 1px solid var(--hairline-color);
}
#changelist .changelist-footer .paginator {
color: var(--body-quiet-color);
background: var(--body-bg);
border: none;
padding: 0;
}
#changelist .paginator { #changelist .paginator {
color: var(--body-quiet-color); color: var(--body-quiet-color);
border-bottom: 1px solid var(--hairline-color); border-bottom: 1px solid var(--hairline-color);
background: var(--body-bg); background: var(--body-bg);
overflow: hidden; }
#changelist .paginator ul {
padding: 0;
white-space: nowrap;
} }
/* CHANGELIST TABLES */ /* CHANGELIST TABLES */

View File

@@ -20,9 +20,17 @@
--border-color: #353535; --border-color: #353535;
--error-fg: #e35f5f; --error-fg: #e35f5f;
--message-debug-bg: #4e4e4e;
--message-debug-icon: url(../img/icon-debug-dark.svg);
--message-info-bg: #265895;
--message-info-icon: url(../img/icon-info-dark.svg);
--message-success-bg: #006b1b; --message-success-bg: #006b1b;
--message-success-icon: url(../img/icon-yes-dark.svg);
--message-warning-bg: #583305; --message-warning-bg: #583305;
--message-warning-icon: url(../img/icon-alert-dark.svg);
--message-error-bg: #570808; --message-error-bg: #570808;
--message-error-icon: url(../img/icon-no-dark.svg);
--darkened-bg: #212121; --darkened-bg: #212121;
--selected-bg: #1b1b1b; --selected-bg: #1b1b1b;
@@ -57,9 +65,17 @@ html[data-theme="dark"] {
--border-color: #353535; --border-color: #353535;
--error-fg: #e35f5f; --error-fg: #e35f5f;
--message-debug-bg: #4e4e4e;
--message-debug-icon: url(../img/icon-debug-dark.svg);
--message-info-bg: #265895;
--message-info-icon: url(../img/icon-info-dark.svg);
--message-success-bg: #006b1b; --message-success-bg: #006b1b;
--message-success-icon: url(../img/icon-yes-dark.svg);
--message-warning-bg: #583305; --message-warning-bg: #583305;
--message-warning-icon: url(../img/icon-alert-dark.svg);
--message-error-bg: #570808; --message-error-bg: #570808;
--message-error-icon: url(../img/icon-no-dark.svg);
--darkened-bg: #212121; --darkened-bg: #212121;
--selected-bg: #1b1b1b; --selected-bg: #1b1b1b;
@@ -84,8 +100,8 @@ html[data-theme="dark"] {
.theme-toggle svg { .theme-toggle svg {
vertical-align: middle; vertical-align: middle;
height: 1rem; height: 1.5rem;
width: 1rem; width: 1.5rem;
display: none; display: none;
} }

View File

@@ -36,12 +36,13 @@ form .form-row p {
/* FORM LABELS */ /* FORM LABELS */
label { legend, label {
font-weight: normal; font-weight: normal;
color: var(--body-quiet-color); color: var(--body-quiet-color);
font-size: 0.8125rem; font-size: 0.8125rem;
} }
.required legend, legend.required,
.required label, label.required { .required label, label.required {
font-weight: bold; font-weight: bold;
} }
@@ -91,6 +92,20 @@ fieldset .inline-heading,
/* ALIGNED FIELDSETS */ /* ALIGNED FIELDSETS */
.aligned fieldset {
width: 100%;
border-top: none;
}
.aligned fieldset > div {
width: 100%;
}
.aligned legend {
float: inline-start;
}
.aligned legend,
.aligned label { .aligned label {
display: block; display: block;
padding: 4px 10px 0 0; padding: 4px 10px 0 0;
@@ -133,7 +148,7 @@ form .aligned ul {
} }
form .aligned div.radiolist { form .aligned div.radiolist {
display: inline-block; display: block;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@@ -169,6 +184,10 @@ form .aligned select + div.help {
padding-left: 10px; padding-left: 10px;
} }
form .aligned select option:checked {
background-color: var(--selected-row);
}
form .aligned ul li { form .aligned ul li {
list-style: none; list-style: none;
} }
@@ -334,7 +353,7 @@ body.popup .submit-row {
width: 48em; width: 48em;
} }
.flatpages-flatpage #id_content { .app-flatpages.model-flatpage #id_content {
height: 40.2em; height: 40.2em;
} }
@@ -409,9 +428,12 @@ body.popup .submit-row {
border: none; border: none;
} }
.inline-related.tabular div.wrapper {
overflow-x: auto;
}
.inline-related.tabular fieldset.module table { .inline-related.tabular fieldset.module table {
width: 100%; width: 100%;
overflow-x: scroll;
} }
.last-related fieldset { .last-related fieldset {
@@ -425,7 +447,6 @@ body.popup .submit-row {
.inline-group .tabular tr td.original { .inline-group .tabular tr td.original {
padding: 2px 0 0 0; padding: 2px 0 0 0;
width: 0; width: 0;
_position: relative;
} }
.inline-group .tabular th.original { .inline-group .tabular th.original {
@@ -433,27 +454,19 @@ body.popup .submit-row {
padding: 0; padding: 0;
} }
.inline-group .tabular td {
font-size: 1rem;
}
.inline-group .tabular td.original p { .inline-group .tabular td.original p {
position: absolute; position: absolute;
left: 0; left: 0;
height: 1.1em; height: 1.2em;
padding: 2px 9px; padding: 2px 9px;
overflow: hidden; overflow: hidden;
font-size: 0.5625rem; font-size: 0.875rem;
font-weight: bold; font-weight: bold;
color: var(--body-quiet-color); color: var(--body-quiet-color);
_width: 700px;
}
.inline-group ul.tools {
padding: 0;
margin: 0;
list-style: none;
}
.inline-group ul.tools li {
display: inline;
padding: 0 5px;
} }
.inline-group div.add-row, .inline-group div.add-row,
@@ -469,11 +482,8 @@ body.popup .submit-row {
border-bottom: 1px solid var(--hairline-color); border-bottom: 1px solid var(--hairline-color);
} }
.inline-group ul.tools a.add,
.inline-group div.add-row a, .inline-group div.add-row a,
.inline-group .tabular tr.add-row td a { .inline-group .tabular tr.add-row td a {
background: url(../img/icon-addlink.svg) 0 1px no-repeat;
padding-left: 16px;
font-size: 0.75rem; font-size: 0.75rem;
} }

View File

@@ -170,6 +170,7 @@ input[type="submit"], button {
/* Forms */ /* Forms */
legend,
label { label {
font-size: 1rem; font-size: 1rem;
} }
@@ -254,10 +255,6 @@ input[type="submit"], button {
align-items: center; align-items: center;
} }
.selector .selector-filter label {
margin: 0 8px 0 0;
}
.selector .selector-filter input { .selector .selector-filter input {
width: 100%; width: 100%;
min-height: 0; min-height: 0;
@@ -277,29 +274,7 @@ input[type="submit"], button {
margin-bottom: 5px; margin-bottom: 5px;
} }
.selector ul.selector-chooser { .selector-chooseall, .selector-clearall {
width: 26px;
height: 52px;
padding: 2px 0;
border-radius: 20px;
transform: translateY(-10px);
}
.selector-add, .selector-remove {
width: 20px;
height: 20px;
background-size: 20px auto;
}
.selector-add {
background-position: 0 -120px;
}
.selector-remove {
background-position: 0 -80px;
}
a.selector-chooseall, a.selector-clearall {
align-self: center; align-self: center;
} }
@@ -321,8 +296,6 @@ input[type="submit"], button {
} }
.stacked ul.selector-chooser { .stacked ul.selector-chooser {
width: 52px;
height: 26px;
padding: 0 2px; padding: 0 2px;
transform: none; transform: none;
} }
@@ -331,42 +304,6 @@ input[type="submit"], button {
padding: 3px; padding: 3px;
} }
.stacked .selector-add, .stacked .selector-remove {
background-size: 20px auto;
}
.stacked .selector-add {
background-position: 0 -40px;
}
.stacked .active.selector-add {
background-position: 0 -40px;
}
.active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -140px;
}
.stacked .active.selector-add:focus, .stacked .active.selector-add:hover {
background-position: 0 -60px;
}
.stacked .selector-remove {
background-position: 0 0;
}
.stacked .active.selector-remove {
background-position: 0 0;
}
.active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -100px;
}
.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover {
background-position: 0 -20px;
}
.help-tooltip, .selector .help-icon { .help-tooltip, .selector .help-icon {
display: none; display: none;
} }
@@ -401,16 +338,8 @@ input[type="submit"], button {
/* Messages */ /* Messages */
ul.messagelist li { ul.messagelist li {
padding-left: 55px; padding: 10px 10px 10px 55px;
background-position: 30px 12px; background-position-x: 30px;
}
ul.messagelist li.error {
background-position: 30px 12px;
}
ul.messagelist li.warning {
background-position: 30px 14px;
} }
/* Login */ /* Login */
@@ -481,11 +410,15 @@ input[type="submit"], button {
/* Changelist */ /* Changelist */
#changelist { #changelist .changelist-form-container {
align-items: stretch;
flex-direction: column; flex-direction: column;
} }
#changelist .changelist-form-container:has(#changelist-filter) > div {
max-width: 100%;
width: 100%;
}
#toolbar { #toolbar {
padding: 10px; padding: 10px;
} }
@@ -508,25 +441,12 @@ input[type="submit"], button {
} }
#changelist-filter { #changelist-filter {
position: static; width: 100%;
width: auto;
margin-top: 30px; margin-top: 30px;
} }
.object-tools { .object-tools {
float: none; text-align: left;
margin: 0 0 15px;
padding: 0;
overflow: hidden;
}
.object-tools li {
height: auto;
margin-left: 0;
}
.object-tools li + li {
margin-left: 15px;
} }
/* Forms */ /* Forms */
@@ -565,6 +485,7 @@ input[type="submit"], button {
padding-top: 15px; padding-top: 15px;
} }
.aligned legend,
.aligned label { .aligned label {
width: 100%; width: 100%;
min-width: auto; min-width: auto;
@@ -639,6 +560,10 @@ input[type="submit"], button {
margin-top: 5px; margin-top: 5px;
} }
form .aligned fieldset div.flex-container {
display: unset;
}
/* Related widget */ /* Related widget */
.related-widget-wrapper { .related-widget-wrapper {
@@ -649,6 +574,7 @@ input[type="submit"], button {
.related-widget-wrapper .selector { .related-widget-wrapper .selector {
order: 1; order: 1;
flex: 1 0 auto;
} }
.related-widget-wrapper > a { .related-widget-wrapper > a {
@@ -679,9 +605,9 @@ input[type="submit"], button {
} }
.selector ul.selector-chooser { .selector ul.selector-chooser {
display: block; display: flex;
width: 52px; width: 60px;
height: 26px; height: 30px;
padding: 0 2px; padding: 0 2px;
transform: none; transform: none;
} }
@@ -694,16 +620,16 @@ input[type="submit"], button {
background-position: 0 0; background-position: 0 0;
} }
.active.selector-remove:focus, .active.selector-remove:hover { :enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -20px; background-position: 0 -24px;
} }
.selector-add { .selector-add {
background-position: 0 -40px; background-position: 0 -48px;
} }
.active.selector-add:focus, .active.selector-add:hover { :enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -60px; background-position: 0 -72px;
} }
/* Inlines */ /* Inlines */
@@ -802,16 +728,8 @@ input[type="submit"], button {
/* Messages */ /* Messages */
ul.messagelist li { ul.messagelist li {
padding-left: 40px; padding: 10px 10px 10px 40px;
background-position: 15px 12px; background-position-x: 15px;
}
ul.messagelist li.error {
background-position: 15px 12px;
}
ul.messagelist li.warning {
background-position: 15px 14px;
} }
/* Paginator */ /* Paginator */

View File

@@ -28,46 +28,20 @@
margin-left: 0; margin-left: 0;
} }
[dir="rtl"] .inline-group ul.tools a.add,
[dir="rtl"] .inline-group div.add-row a, [dir="rtl"] .inline-group div.add-row a,
[dir="rtl"] .inline-group .tabular tr.add-row td a { [dir="rtl"] .inline-group .tabular tr.add-row td a {
padding: 8px 26px 8px 10px; padding: 8px 26px 8px 10px;
background-position: calc(100% - 8px) 9px; background-position: calc(100% - 8px) 9px;
} }
[dir="rtl"] .selector .selector-filter label {
margin-right: 0;
margin-left: 8px;
}
[dir="rtl"] .object-tools li {
float: right;
}
[dir="rtl"] .object-tools li + li {
margin-left: 0;
margin-right: 15px;
}
[dir="rtl"] .dashboard .module table td a { [dir="rtl"] .dashboard .module table td a {
padding-left: 0; padding-left: 0;
padding-right: 16px; padding-right: 16px;
} }
[dir="rtl"] .selector-add { [dir="rtl"] ul.messagelist li {
background-position: 0 -80px; padding: 10px 55px 10px 10px;
} background-position-x: calc(100% - 30px);
[dir="rtl"] .selector-remove {
background-position: 0 -120px;
}
[dir="rtl"] .active.selector-add:focus, .active.selector-add:hover {
background-position: 0 -100px;
}
[dir="rtl"] .active.selector-remove:focus, .active.selector-remove:hover {
background-position: 0 -140px;
} }
} }
@@ -89,6 +63,11 @@
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }
[dir="rtl"] .object-tools {
text-align: right;
}
[dir="rtl"] .aligned .vCheckboxLabel { [dir="rtl"] .aligned .vCheckboxLabel {
padding: 1px 5px 0 0; padding: 1px 5px 0 0;
} }
@@ -97,15 +76,20 @@
background-position: 0 0; background-position: 0 0;
} }
[dir="rtl"] .active.selector-remove:focus, .active.selector-remove:hover { [dir="rtl"] :enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -20px; background-position: 0 -24px;
} }
[dir="rtl"] .selector-add { [dir="rtl"] .selector-add {
background-position: 0 -40px; background-position: 0 -48px;
} }
[dir="rtl"] .active.selector-add:focus, .active.selector-add:hover { [dir="rtl"] :enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -60px; background-position: 0 -72px;
}
[dir="rtl"] ul.messagelist li {
padding: 10px 40px 10px 10px;
background-position-x: calc(100% - 15px);
} }
} }

View File

@@ -26,7 +26,12 @@ th {
} }
.object-tools { .object-tools {
float: left; text-align: left;
}
.object-tools li + li {
margin-right: 15px;
margin-left: 0;
} }
thead th:first-child, thead th:first-child,
@@ -107,7 +112,7 @@ thead th.sorted .text {
border-left: none; border-left: none;
} }
.paginator .end { .paginator ul {
margin-left: 6px; margin-left: 6px;
margin-right: 0; margin-right: 0;
} }
@@ -220,34 +225,28 @@ fieldset .fieldBox {
} }
.selector-add { .selector-add {
background: url(../img/selector-icons.svg) 0 -64px no-repeat; background: url(../img/selector-icons.svg) 0 -96px no-repeat;
background-size: 24px auto;
} }
.active.selector-add:focus, .active.selector-add:hover { :enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -80px; background-position: 0 -120px;
} }
.selector-remove { .selector-remove {
background: url(../img/selector-icons.svg) 0 -96px no-repeat; background: url(../img/selector-icons.svg) 0 -144px no-repeat;
background-size: 24px auto;
} }
.active.selector-remove:focus, .active.selector-remove:hover { :enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -112px; background-position: 0 -168px;
} }
a.selector-chooseall { :enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover {
background: url(../img/selector-icons.svg) right -128px no-repeat;
}
a.active.selector-chooseall:focus, a.active.selector-chooseall:hover {
background-position: 100% -144px; background-position: 100% -144px;
} }
a.selector-clearall { :enabled.selector-clearall:focus, :enabled.selector-clearall:hover {
background: url(../img/selector-icons.svg) 0 -160px no-repeat;
}
a.active.selector-clearall:focus, a.active.selector-clearall:hover {
background-position: 0 -176px; background-position: 0 -176px;
} }
@@ -289,3 +288,8 @@ form .form-row p.datetime {
.selector .selector-chooser { .selector .selector-chooser {
margin: 0; margin: 0;
} }
ul.messagelist li {
padding: 10px 65px 10px 10px;
background-position-x: calc(100% - 40px);
}

View File

@@ -2,7 +2,7 @@
.selector { .selector {
display: flex; display: flex;
flex-grow: 1; flex: 1;
gap: 0 10px; gap: 0 10px;
} }
@@ -14,17 +14,20 @@
} }
.selector-available, .selector-chosen { .selector-available, .selector-chosen {
text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1; flex: 1 1;
} }
.selector-available h2, .selector-chosen h2 { .selector-available-title, .selector-chosen-title {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
} }
.selector .helptext {
font-size: 0.6875rem;
}
.selector-chosen .list-footer-display { .selector-chosen .list-footer-display {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-top: none; border-top: none;
@@ -40,14 +43,25 @@
color: var(--breadcrumbs-fg); color: var(--breadcrumbs-fg);
} }
.selector-chosen h2 { .selector-chosen-title {
background: var(--secondary); background: var(--secondary);
color: var(--header-link-color); color: var(--header-link-color);
padding: 8px;
} }
.selector .selector-available h2 { .selector-chosen-title label {
color: var(--header-link-color);
width: 100%;
}
.selector-available-title {
background: var(--darkened-bg); background: var(--darkened-bg);
color: var(--body-quiet-color); color: var(--body-quiet-color);
padding: 8px;
}
.selector-available-title label {
width: 100%;
} }
.selector .selector-filter { .selector .selector-filter {
@@ -59,6 +73,7 @@
margin: 0; margin: 0;
text-align: left; text-align: left;
display: flex; display: flex;
gap: 8px;
} }
.selector .selector-filter label, .selector .selector-filter label,
@@ -77,14 +92,9 @@
flex-grow: 1; flex-grow: 1;
} }
.selector .selector-available input,
.selector .selector-chosen input {
margin-left: 8px;
}
.selector ul.selector-chooser { .selector ul.selector-chooser {
align-self: center; align-self: center;
width: 22px; width: 30px;
background-color: var(--selected-bg); background-color: var(--selected-bg);
border-radius: 10px; border-radius: 10px;
margin: 0; margin: 0;
@@ -114,82 +124,74 @@
} }
.selector-add, .selector-remove { .selector-add, .selector-remove {
width: 16px; width: 24px;
height: 16px; height: 24px;
display: block; display: block;
text-indent: -3000px; text-indent: -3000px;
overflow: hidden; overflow: hidden;
cursor: default; cursor: default;
opacity: 0.55; opacity: 0.55;
border: none;
} }
.active.selector-add, .active.selector-remove { :enabled.selector-add, :enabled.selector-remove {
opacity: 1; opacity: 1;
} }
.active.selector-add:hover, .active.selector-remove:hover { :enabled.selector-add:hover, :enabled.selector-remove:hover {
cursor: pointer; cursor: pointer;
} }
.selector-add { .selector-add {
background: url(../img/selector-icons.svg) 0 -96px no-repeat; background: url(../img/selector-icons.svg) 0 -144px no-repeat;
background-size: 24px auto;
} }
.active.selector-add:focus, .active.selector-add:hover { :enabled.selector-add:focus, :enabled.selector-add:hover {
background-position: 0 -112px; background-position: 0 -168px;
} }
.selector-remove { .selector-remove {
background: url(../img/selector-icons.svg) 0 -64px no-repeat; background: url(../img/selector-icons.svg) 0 -96px no-repeat;
background-size: 24px auto;
} }
.active.selector-remove:focus, .active.selector-remove:hover { :enabled.selector-remove:focus, :enabled.selector-remove:hover {
background-position: 0 -80px; background-position: 0 -120px;
} }
a.selector-chooseall, a.selector-clearall { .selector-chooseall, .selector-clearall {
display: inline-block; display: inline-block;
height: 16px;
text-align: left; text-align: left;
padding: 4px 5px;
margin: 0 auto; margin: 0 auto;
overflow: hidden; overflow: hidden;
font-weight: bold; color: var(--button-fg);
line-height: 16px; background-color: var(--button-bg);
color: var(--body-quiet-color);
text-decoration: none; text-decoration: none;
opacity: 0.55; opacity: 0.55;
border: none;
border-radius: 4px;
} }
a.active.selector-chooseall:focus, a.active.selector-clearall:focus, :enabled.selector-chooseall:focus, :enabled.selector-clearall:focus,
a.active.selector-chooseall:hover, a.active.selector-clearall:hover { :enabled.selector-chooseall:hover, :enabled.selector-clearall:hover {
color: var(--link-fg); background-color: var(--button-hover-bg);
} }
a.active.selector-chooseall, a.active.selector-clearall { :enabled.selector-chooseall, :enabled.selector-clearall {
opacity: 1; opacity: 1;
} }
a.active.selector-chooseall:hover, a.active.selector-clearall:hover { :enabled.selector-chooseall:hover, :enabled.selector-clearall:hover {
cursor: pointer; cursor: pointer;
} }
a.selector-chooseall { :enabled.selector-chooseall:focus, :enabled.selector-chooseall:hover {
padding: 0 18px 0 0;
background: url(../img/selector-icons.svg) right -160px no-repeat;
cursor: default;
}
a.active.selector-chooseall:focus, a.active.selector-chooseall:hover {
background-position: 100% -176px; background-position: 100% -176px;
} }
a.selector-clearall { :enabled.selector-clearall:focus, :enabled.selector-clearall:hover {
padding: 0 0 0 18px;
background: url(../img/selector-icons.svg) 0 -128px no-repeat;
cursor: default;
}
a.active.selector-clearall:focus, a.active.selector-clearall:hover {
background-position: 0 -144px; background-position: 0 -144px;
} }
@@ -219,8 +221,9 @@ a.active.selector-clearall:focus, a.active.selector-clearall:hover {
} }
.stacked ul.selector-chooser { .stacked ul.selector-chooser {
height: 22px; display: flex;
width: 50px; height: 30px;
width: 64px;
margin: 0 0 10px 40%; margin: 0 0 10px 40%;
background-color: #eee; background-color: #eee;
border-radius: 10px; border-radius: 10px;
@@ -237,32 +240,34 @@ a.active.selector-clearall:focus, a.active.selector-clearall:hover {
} }
.stacked .selector-add { .stacked .selector-add {
background: url(../img/selector-icons.svg) 0 -32px no-repeat; background: url(../img/selector-icons.svg) 0 -48px no-repeat;
background-size: 24px auto;
cursor: default; cursor: default;
} }
.stacked .active.selector-add { .stacked :enabled.selector-add {
background-position: 0 -32px; background-position: 0 -48px;
cursor: pointer; cursor: pointer;
} }
.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { .stacked :enabled.selector-add:focus, .stacked :enabled.selector-add:hover {
background-position: 0 -48px; background-position: 0 -72px;
cursor: pointer; cursor: pointer;
} }
.stacked .selector-remove { .stacked .selector-remove {
background: url(../img/selector-icons.svg) 0 0 no-repeat; background: url(../img/selector-icons.svg) 0 0 no-repeat;
background-size: 24px auto;
cursor: default; cursor: default;
} }
.stacked .active.selector-remove { .stacked :enabled.selector-remove {
background-position: 0 0px; background-position: 0 0px;
cursor: pointer; cursor: pointer;
} }
.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { .stacked :enabled.selector-remove:focus, .stacked :enabled.selector-remove:hover {
background-position: 0 -16px; background-position: 0 -24px;
cursor: pointer; cursor: pointer;
} }
@@ -296,6 +301,10 @@ p.datetime {
font-weight: bold; font-weight: bold;
} }
p.datetime label {
display: inline;
}
.datetime span { .datetime span {
white-space: nowrap; white-space: nowrap;
font-weight: normal; font-weight: normal;
@@ -318,28 +327,30 @@ table p.datetime {
position: relative; position: relative;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
height: 16px; height: 24px;
width: 16px; width: 24px;
overflow: hidden; overflow: hidden;
} }
.datetimeshortcuts .clock-icon { .datetimeshortcuts .clock-icon {
background: url(../img/icon-clock.svg) 0 0 no-repeat; background: url(../img/icon-clock.svg) 0 0 no-repeat;
background-size: 24px auto;
} }
.datetimeshortcuts a:focus .clock-icon, .datetimeshortcuts a:focus .clock-icon,
.datetimeshortcuts a:hover .clock-icon { .datetimeshortcuts a:hover .clock-icon {
background-position: 0 -16px; background-position: 0 -24px;
} }
.datetimeshortcuts .date-icon { .datetimeshortcuts .date-icon {
background: url(../img/icon-calendar.svg) 0 0 no-repeat; background: url(../img/icon-calendar.svg) 0 0 no-repeat;
background-size: 24px auto;
top: -1px; top: -1px;
} }
.datetimeshortcuts a:focus .date-icon, .datetimeshortcuts a:focus .date-icon,
.datetimeshortcuts a:hover .date-icon { .datetimeshortcuts a:hover .date-icon {
background-position: 0 -16px; background-position: 0 -24px;
} }
.timezonewarning { .timezonewarning {
@@ -557,10 +568,12 @@ ul.timelist, .timelist li {
.inline-deletelink { .inline-deletelink {
float: right; float: right;
text-indent: -9999px; text-indent: -9999px;
background: url(../img/inline-delete.svg) 0 0 no-repeat; background: url(../img/inline-delete.svg) center center no-repeat;
width: 16px; background-size: contain;
height: 16px; width: 1.5rem;
height: 1.5rem;
border: 0px none; border: 0px none;
margin-bottom: .25rem;
} }
.inline-deletelink:focus, .inline-deletelink:hover { .inline-deletelink:focus, .inline-deletelink:hover {

View File

@@ -0,0 +1,80 @@
# Information about icons in this directory
## License
All icons in this directory are provided by
[Font Awesome Free](https://fontawesome.com), version 6.7.2.
- The icons are licensed under the [Creative Commons Attribution 4.0
International (CC-BY-4.0)](https://creativecommons.org/licenses/by/4.0/)
license.
- This license allows you to use, modify, and distribute the icons, provided
proper attribution is given.
## Usage
- You may use, modify, and distribute the icons in this repository in
compliance with the [Creative Commons Attribution 4.0 International
(CC-BY-4.0)](https://creativecommons.org/licenses/by/4.0/) license.
## Modifications
- These icons have been resized, recolored, or otherwise modified to fit the
requirements of this project.
- These modifications alter the appearance of the original icons but remain
covered under the terms of the
[CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) license.
## Contributing SVG Icons
To ensure visual consistency, traceability, and proper license attribution,
follow these guidelines. This applies when adding or modifying icons.
## ⚠️ Important: Changing Font Awesome Version
If you update to a different Font Awesome version, you must **update all SVG
files** and **comments inside the files** to reflect the new version number and
licensing URL accordingly. For example:
* Original:
```xml
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
```
* Updated:
```xml
<!--!Font Awesome Free X.Y.Z by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright YYYY Fonticons, Inc.-->
```
## Adding a new icon
1. Use only [Font Awesome Free Icons](https://fontawesome.com/icons).
2. Save the icon as an .svg file in this directory.
3. Include the following attribution comment at the top of the file (do not
change it):
```xml
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
```
4. Right before the `<path>` element, add the following metadata comment with
the appropriate values:
```xml
<!--
Icon Name: [icon-name]
Icon Family: [classic | sharp | brands | etc.]
Icon Style: [solid | regular | light | thin | duotone | etc.]
-->
```
### Example SVG Structure
```xml
<svg width="13" height="13" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!--
Icon Name: plus
Icon Family: classic
Icon Style: solid
-->
<path fill="#5fa225" stroke="#5fa225" stroke-width="30" d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/>
</svg>
```

View File

@@ -1,63 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg <svg
width="15" width="15"
height="30" height="30"
viewBox="0 0 1792 3584" viewBox="0 0 512 1024"
version="1.1" version="1.1"
id="svg5" id="svg5"
sodipodi:docname="calendar-icons.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg">
xmlns:svg="http://www.w3.org/2000/svg"> <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<sodipodi:namedview <defs id="defs2">
id="namedview5" <g id="previous">
pagecolor="#ffffff" <!--
bordercolor="#666666" Icon Name: circle-chevron-left
borderopacity="1.0" Icon Family: classic
inkscape:showpageshadow="2" Icon Style: solid
inkscape:pageopacity="0.0" -->
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="13.3"
inkscape:cx="15.526316"
inkscape:cy="20.977444"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<defs
id="defs2">
<g
id="previous">
<path <path
d="m 1037,1395 102,-102 q 19,-19 19,-45 0,-26 -19,-45 L 832,896 1139,589 q 19,-19 19,-45 0,-26 -19,-45 L 1037,397 q -19,-19 -45,-19 -26,0 -45,19 L 493,851 q -19,19 -19,45 0,26 19,45 l 454,454 q 19,19 45,19 26,0 45,-19 z m 627,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM271 135c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-87 87 87 87c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0L167 273c-9.4-9.4-9.4-24.6 0-33.9L271 135z"
id="path1" />
</g>
<g
id="next">
<path
d="m 845,1395 454,-454 q 19,-19 19,-45 0,-26 -19,-45 L 845,397 q -19,-19 -45,-19 -26,0 -45,19 L 653,499 q -19,19 -19,45 0,26 19,45 l 307,307 -307,307 q -19,19 -19,45 0,26 19,45 l 102,102 q 19,19 45,19 26,0 45,-19 z m 819,-499 q 0,209 -103,385.5 Q 1458,1458 1281.5,1561 1105,1664 896,1664 687,1664 510.5,1561 334,1458 231,1281.5 128,1105 128,896 128,687 231,510.5 334,334 510.5,231 687,128 896,128 1105,128 1281.5,231 1458,334 1561,510.5 1664,687 1664,896 Z"
id="path2" /> id="path2" />
</g> </g>
<g id="next">
<!--
Icon Name: circle-chevron-right
Icon Family: classic
Icon Style: solid
-->
<path
d="M0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zM241 377c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l87-87-87-87c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0L345 239c9.4 9.4 9.4 24.6 0 33.9L241 377z"
id="path1" />
</g>
</defs> </defs>
<use <use
xlink:href="#next" xlink:href="#next"
x="0" x="0"
y="5376" y="512"
fill="#000000" fill="#000000"
id="use5" id="use5" />
transform="translate(0,-3584)" />
<use <use
xlink:href="#previous" xlink:href="#previous"
x="0" x="0"
y="0" y="0"
fill="#333333" fill="#333333"
id="use2" id="use2" />
style="fill:#000000;fill-opacity:1" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +1,9 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"> <svg width="13" height="13" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path fill="#5fa225" d="M1600 796v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"/> <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!--
Icon Name: plus
Icon Family: classic
Icon Style: solid
-->
<path fill="#5fa225" stroke="#5fa225" stroke-width="30" d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 331 B

After

Width:  |  Height:  |  Size: 593 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="14" height="14">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!--
Icon Name: triangle-exclamation
Icon Family: classic
Icon Style: solid
-->
<path fill="#efb80b" d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@@ -1,3 +1,9 @@
<svg width="14" height="14" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="14" height="14">
<path fill="#efb80b" d="M1024 1375v-190q0-14-9.5-23.5t-22.5-9.5h-192q-13 0-22.5 9.5t-9.5 23.5v190q0 14 9.5 23.5t22.5 9.5h192q13 0 22.5-9.5t9.5-23.5zm-2-374l18-459q0-12-10-19-13-11-24-11h-220q-11 0-24 11-10 7-10 21l17 457q0 10 10 16.5t24 6.5h185q14 0 23.5-6.5t10.5-16.5zm-14-934l768 1408q35 63-2 126-17 29-46.5 46t-63.5 17h-1536q-34 0-63.5-17t-46.5-46q-37-63-2-126l768-1408q17-31 47-49t65-18 65 18 47 49z"/> <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!--
Icon Name: triangle-exclamation
Icon Family: classic
Icon Style: solid
-->
<path fill="#b78b02" d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 684 B

View File

@@ -1,9 +1,15 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="16" height="32" viewBox="0 0 448 1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<defs> <defs>
<g id="icon"> <g id="icon">
<path d="M192 1664h288v-288h-288v288zm352 0h320v-288h-320v288zm-352-352h288v-320h-288v320zm352 0h320v-320h-320v320zm-352-384h288v-288h-288v288zm736 736h320v-288h-320v288zm-384-736h320v-288h-320v288zm768 736h288v-288h-288v288zm-384-352h320v-320h-320v320zm-352-864v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm736 864h288v-320h-288v320zm-384-384h320v-288h-320v288zm384 0h288v-288h-288v288zm32-480v-288q0-13-9.5-22.5t-22.5-9.5h-64q-13 0-22.5 9.5t-9.5 22.5v288q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5-9.5t9.5-22.5zm384-64v1280q0 52-38 90t-90 38h-1408q-52 0-90-38t-38-90v-1280q0-52 38-90t90-38h128v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h384v-96q0-66 47-113t113-47h64q66 0 113 47t47 113v96h128q52 0 90 38t38 90z"/> <!--
Icon Name: calendar-days
Icon Family: classic
Icon Style: regular
-->
<path d="M152 24c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40L64 64C28.7 64 0 92.7 0 128l0 16 0 48L0 448c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-256 0-48 0-16c0-35.3-28.7-64-64-64l-40 0 0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40L152 64l0-40zM48 192l80 0 0 56-80 0 0-56zm0 104l80 0 0 64-80 0 0-64zm128 0l96 0 0 64-96 0 0-64zm144 0l80 0 0 64-80 0 0-64zm80-48l-80 0 0-56 80 0 0 56zm0 160l0 40c0 8.8-7.2 16-16 16l-64 0 0-56 80 0zm-128 0l0 56-96 0 0-56 96 0zm-144 0l0 56-64 0c-8.8 0-16-7.2-16-16l0-40 80 0zM272 248l-96 0 0-56 96 0 0 56z"/>
</g> </g>
</defs> </defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" /> <use xlink:href="#icon" x="0" y="0" fill="#447e9b" />
<use xlink:href="#icon" x="0" y="1792" fill="#003366" /> <use xlink:href="#icon" x="0" y="512" fill="#003366" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,9 @@
<svg width="13" height="13" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"> <svg width="13" height="13" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="#b48c08" d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/> <!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!--
Icon Name: pencil
Icon Family: classic
Icon Style: solid
-->
<path fill="#b48c08" d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 978 B

View File

@@ -1,9 +1,15 @@
<svg width="16" height="32" viewBox="0 0 1792 3584" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="16" height="32" viewBox="0 0 512 1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<defs> <defs>
<g id="icon"> <g id="icon">
<path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/> <!--
Icon Name: clock
Icon Family: classic
Icon Style: regular
-->
<path d="M464 256A208 208 0 1 1 48 256a208 208 0 1 1 416 0zM0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zM232 120l0 136c0 8 4 15.5 10.7 20l96 64c11 7.4 25.9 4.4 33.3-6.7s4.4-25.9-6.7-33.3L280 243.2 280 120c0-13.3-10.7-24-24-24s-24 10.7-24 24z"/>
</g> </g>
</defs> </defs>
<use xlink:href="#icon" x="0" y="0" fill="#447e9b" /> <use xlink:href="#icon" x="0" y="0" fill="#447e9b"/>
<use xlink:href="#icon" x="0" y="1792" fill="#003366" /> <use xlink:href="#icon" x="0" y="512" fill="#003366" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 677 B

After

Width:  |  Height:  |  Size: 805 B

View File

@@ -0,0 +1,9 @@
<svg width="13" height="13" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. -->
<!--
Icon Name: bug
Icon Family: classic
Icon Style: solid
-->
<path fill="#bfbfbf" d="M256 0c53 0 96 43 96 96l0 3.6c0 15.7-12.7 28.4-28.4 28.4l-135.1 0c-15.7 0-28.4-12.7-28.4-28.4l0-3.6c0-53 43-96 96-96zM41.4 105.4c12.5-12.5 32.8-12.5 45.3 0l64 64c.7 .7 1.3 1.4 1.9 2.1c14.2-7.3 30.4-11.4 47.5-11.4l112 0c17.1 0 33.2 4.1 47.5 11.4c.6-.7 1.2-1.4 1.9-2.1l64-64c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-64 64c-.7 .7-1.4 1.3-2.1 1.9c6.2 12 10.1 25.3 11.1 39.5l64.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c0 24.6-5.5 47.8-15.4 68.6c2.2 1.3 4.2 2.9 6 4.8l64 64c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-63.1-63.1c-24.5 21.8-55.8 36.2-90.3 39.6L272 240c0-8.8-7.2-16-16-16s-16 7.2-16 16l0 239.2c-34.5-3.4-65.8-17.8-90.3-39.6L86.6 502.6c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l64-64c1.9-1.9 3.9-3.4 6-4.8C101.5 367.8 96 344.6 96 320l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64.3 0c1.1-14.1 5-27.5 11.1-39.5c-.7-.6-1.4-1.2-2.1-1.9l-64-64c-12.5-12.5-12.5-32.8 0-45.3z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,9 @@
<svg width="13" height="13" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. -->
<!--
Icon Name: bug
Icon Family: classic
Icon Style: solid
-->
<path fill="#808080" d="M256 0c53 0 96 43 96 96l0 3.6c0 15.7-12.7 28.4-28.4 28.4l-135.1 0c-15.7 0-28.4-12.7-28.4-28.4l0-3.6c0-53 43-96 96-96zM41.4 105.4c12.5-12.5 32.8-12.5 45.3 0l64 64c.7 .7 1.3 1.4 1.9 2.1c14.2-7.3 30.4-11.4 47.5-11.4l112 0c17.1 0 33.2 4.1 47.5 11.4c.6-.7 1.2-1.4 1.9-2.1l64-64c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3l-64 64c-.7 .7-1.4 1.3-2.1 1.9c6.2 12 10.1 25.3 11.1 39.5l64.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c0 24.6-5.5 47.8-15.4 68.6c2.2 1.3 4.2 2.9 6 4.8l64 64c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-63.1-63.1c-24.5 21.8-55.8 36.2-90.3 39.6L272 240c0-8.8-7.2-16-16-16s-16 7.2-16 16l0 239.2c-34.5-3.4-65.8-17.8-90.3-39.6L86.6 502.6c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l64-64c1.9-1.9 3.9-3.4 6-4.8C101.5 367.8 96 344.6 96 320l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64.3 0c1.1-14.1 5-27.5 11.1-39.5c-.7-.6-1.4-1.2-2.1-1.9l-64-64c-12.5-12.5-12.5-32.8 0-45.3z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Some files were not shown because too many files have changed in this diff Show More