Compare commits

...

11 Commits

76 changed files with 4792 additions and 389 deletions

View File

@@ -23,3 +23,25 @@ PATCH `/api/v1/servers/{server_id}`
"display_name": "Keywarden Prod" "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?

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

View File

@@ -82,7 +82,7 @@ func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config)
log.Printf("host update error: %v", err) log.Printf("host update error: %v", err)
} }
} }
if err := apiClient.SyncAccounts(ctx, cfg.ServerID); err != nil { 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 {
@@ -144,11 +144,16 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
func reportHost(ctx context.Context, apiClient *client.Client, cfg *config.Config) error { func reportHost(ctx context.Context, apiClient *client.Client, cfg *config.Config) error {
info := host.Detect() 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 retry(ctx, []time.Duration{250 * time.Millisecond, time.Second, 2 * time.Second}, func() error {
return apiClient.UpdateHost(ctx, cfg.ServerID, client.HeartbeatRequest{ return apiClient.UpdateHost(ctx, cfg.ServerID, client.HeartbeatRequest{
Host: info.Hostname, Host: info.Hostname,
IPv4: info.IPv4, IPv4: info.IPv4,
IPv6: info.IPv6, IPv6: info.IPv6,
PingMs: pingPtr,
}) })
}) })
} }

View File

@@ -2,7 +2,7 @@
"server_url": "https://keywarden.dev.ntbx.io/api/v1", "server_url": "https://keywarden.dev.ntbx.io/api/v1",
"server_id": "4", "server_id": "4",
"server_ca_path": "", "server_ca_path": "",
"sync_interval_seconds": 30, "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) {
@@ -61,7 +68,36 @@ 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 {
@@ -81,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 == "" {
@@ -114,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
} }
@@ -139,9 +328,10 @@ func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []by
} }
type HeartbeatRequest struct { type HeartbeatRequest struct {
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
IPv4 string `json:"ipv4,omitempty"` IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"` IPv6 string `json:"ipv6,omitempty"`
PingMs *int `json:"ping_ms,omitempty"`
} }
func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody HeartbeatRequest) error { func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody HeartbeatRequest) error {
@@ -164,3 +354,29 @@ func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody Heartb
} }
return nil 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
}

Binary file not shown.

View File

@@ -22,6 +22,9 @@ class AccessRequestAdmin(GuardedModelAdmin):
"requester", "requester",
"server", "server",
"status", "status",
"request_shell",
"request_logs",
"request_users",
"requested_at", "requested_at",
"expires_at", "expires_at",
"decided_by", "decided_by",
@@ -50,6 +53,9 @@ class AccessRequestAdmin(GuardedModelAdmin):
"server", "server",
"status", "status",
"reason", "reason",
"request_shell",
"request_logs",
"request_users",
"expires_at", "expires_at",
) )
}, },
@@ -64,6 +70,9 @@ class AccessRequestAdmin(GuardedModelAdmin):
"server", "server",
"status", "status",
"reason", "reason",
"request_shell",
"request_logs",
"request_users",
"expires_at", "expires_at",
) )
}, },

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

@@ -16,11 +16,7 @@ def assign_access_request_perms(sender, instance: AccessRequest, created: bool,
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) sync_server_view_perm(instance)

View File

@@ -8,9 +8,32 @@ class ErasureRequestForm(forms.Form):
attrs={ attrs={
"rows": 4, "rows": 4,
"placeholder": "Explain why you are requesting data erasure.", "placeholder": "Explain why you are requesting data erasure.",
"class": "w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-purple-600 focus:outline-none focus:ring-1 focus:ring-purple-600", "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, min_length=10,
max_length=2000, 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

@@ -78,7 +78,7 @@ class ErasureRequest(models.Model):
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.keys.models import SSHKey from apps.keys.models import SSHCertificate, SSHKey
user = self.user user = self.user
token = uuid.uuid4().hex token = uuid.uuid4().hex
@@ -113,6 +113,7 @@ class ErasureRequest(models.Model):
UserObjectPermission.objects.filter(user=user).delete() UserObjectPermission.objects.filter(user=user).delete()
SSHKey.objects.filter(user=user, is_active=True).update(is_active=False, revoked_at=now) 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).update(reason="[redacted]")
AccessRequest.objects.filter( AccessRequest.objects.filter(
requester=user, requester=user,

View File

@@ -4,32 +4,53 @@
{% 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>
<p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p>
</div>
<form method="post" class="mt-6 space-y-5">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}"> <input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
<div class="space-y-1.5"> <div>
<label class="block text-sm font-medium text-gray-700">Username</label> <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-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600"> <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>
<div class="space-y-1.5"> <div>
<label class="block text-sm font-medium text-gray-700">Password</label> <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-md border-gray-300 shadow-sm focus:border-purple-600 focus:ring-purple-600"> <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"
Sign in 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"
</button> >
</div> Sign in
</button>
</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>

View File

@@ -3,102 +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> </div>
<dd class="mt-1 text-sm text-gray-900">{{ user.username }}</dd> <dl class="mt-6 grid grid-cols-1 gap-4 text-sm text-gray-600 sm:grid-cols-2">
<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">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">Email</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.email }}</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">First 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.first_name|default:"—" }}</dd> <dd class="mt-2 text-sm font-medium text-gray-900">{{ user.first_name|default:"—" }}</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">Last 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.last_name|default:"—" }}</dd>
</div> </div>
</dl> </dl>
</div> </section>
</div>
<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" %}
<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.
<a href="/oidc/authenticate/" class="font-medium text-purple-700 hover:text-purple-800">Link with SSO</a>
</p>
</div>
{% elif auth_mode == "oidc" %}
<p class="text-sm text-gray-600">OIDC is required. Sign-in is managed by your identity provider.</p>
{% else %}
<p class="text-sm text-gray-600">OIDC is disabled. You are using native authentication.</p>
{% endif %}
</div>
</div>
</div>
<div class="mt-6 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">
<h2 class="text-base font-semibold tracking-tight text-gray-900">Data erasure request</h2> <div class="space-y-2">
<p class="mt-2 text-sm text-gray-600"> <h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
Submit a GDPR erasure request to anonymize your account data. An administrator <p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
must review and approve the request before processing.
</p>
{% 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="font-semibold">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> </div>
{% if erasure_request.decided_at %} <div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
<p class="mt-2 text-gray-600"> {% if auth_mode == "hybrid" %}
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}. Optional: Link your account with your identity provider for single sign-on.
{% if erasure_request.decision_reason %} <a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a>
Reason: {{ erasure_request.decision_reason }} {% elif auth_mode == "oidc" %}
{% endif %} OIDC is required. Sign-in is managed by your identity provider.
</p> {% else %}
{% endif %} OIDC is disabled. You are using native authentication.
{% 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-4 space-y-3">
{% csrf_token %}
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="block text-sm font-medium text-gray-700">
Reason for request
</label>
<div class="mt-1">
{{ erasure_form.reason }}
</div>
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %} {% endif %}
</div> </div>
{% if erasure_form.non_field_errors %} </section>
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p> </div>
{% endif %}
<button type="submit" class="inline-flex items-center rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600"> <section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
Submit erasure request <div class="flex flex-wrap items-start justify-between gap-4">
</button> <div>
</form> <h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
{% endif %} <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> </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 %} {% endblock %}

View File

@@ -2,9 +2,14 @@ from django.conf import settings
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 import views as auth_views
from django.contrib.auth.decorators import login_required 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 django.shortcuts import redirect, render
from .forms import ErasureRequestForm from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest from .models import ErasureRequest
@@ -13,25 +18,55 @@ def profile(request):
erasure_request = ( erasure_request = (
ErasureRequest.objects.filter(user=request.user).order_by("-requested_at").first() 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": if request.method == "POST":
form = ErasureRequestForm(request.POST) form_type = request.POST.get("form_type")
if form.is_valid(): if form_type == "ssh_key":
if erasure_request and erasure_request.status == ErasureRequest.Status.PENDING: erasure_form = ErasureRequestForm()
form.add_error(None, "You already have a pending erasure request.") key_form = SSHKeyForm(request.POST)
else: if key_form.is_valid():
ErasureRequest.objects.create( if not can_add_key:
user=request.user, key_form.add_error(None, "You do not have permission to add SSH keys.")
reason=form.cleaned_data["reason"].strip(), else:
) name = key_form.cleaned_data["name"].strip()
return redirect("accounts:profile") 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: else:
form = ErasureRequestForm() 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_request": erasure_request,
"erasure_form": form, "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)

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):
@@ -109,4 +147,4 @@ class AuditLog(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
actor = getattr(self.actor, "username", "system") actor = getattr(self.actor, "username", "system")
return f"[{self.created_at:%Y-%m-%d %H:%M:%S}] {actor}: {self.message}" return f"[{self.created_at:%Y-%m-%d %H:%M:%S}] {actor}: {self.message}"

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

@@ -8,7 +8,7 @@ except ImportError: # Fallback for older Unfold builds without guardian admin s
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin): class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
pass pass
from .models import SSHKey from .models import SSHCertificate, SSHCertificateAuthority, SSHKey
@admin.register(SSHKey) @admin.register(SSHKey)
@@ -17,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

@@ -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

@@ -3,15 +3,24 @@
{% block title %}Servers • Keywarden{% endblock %} {% block title %}Servers • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-8"> <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 %} {% if servers %}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% for item in servers %} {% for item in servers %}
<article class="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"> <article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-600 text-white font-semibold"> <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 }} {{ item.server.initial }}
</div> </div>
<div> <div>
@@ -21,11 +30,56 @@
</p> </p>
</div> </div>
</div> </div>
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700">Active</span> <div class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
>
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Last heartbeat</span>
<span class="font-medium text-gray-900">
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
</span>
</div>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
</div> </div>
<dl class="mt-4 space-y-2 text-sm text-gray-600"> <dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Access until</dt> <dt>Access until</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if item.expires_at %} {% if item.expires_at %}
@@ -35,7 +89,7 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Last accessed</dt> <dt>Last accessed</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if item.last_accessed %} {% if item.last_accessed %}
@@ -47,16 +101,23 @@
</div> </div>
</dl> </dl>
<div class="mt-4 border-t border-gray-100 pt-3 text-xs text-gray-500"> <div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-purple-700 hover:text-purple-800">View details and logs</a> <span>Certificates and access</span>
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-10 text-center"> <div class="rounded-2xl border border-dashed border-gray-200 bg-white p-10 text-center">
<h2 class="text-lg font-semibold text-gray-900">No server access yet</h2> <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it here.</p> <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> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -3,27 +3,107 @@
{% block title %}{{ server.display_name }} • Keywarden{% endblock %} {% block title %}{{ server.display_name }} • Keywarden{% endblock %}
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> {% include "servers/_header.html" %}
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white text-xl font-semibold">
{{ 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-600">
{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}
</p>
</div>
</div>
<a href="{% url 'servers:dashboard' %}" class="text-sm font-semibold text-purple-700 hover:text-purple-800">Back to servers</a>
</div>
<section class="grid gap-4 lg:grid-cols-3"> <section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm lg:col-span-2"> <div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Access</h2> <h2 class="text-lg font-semibold text-gray-900">Server details</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600"> <dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between"> <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> <dt>Access until</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if expires_at %} {% if expires_at %}
@@ -33,7 +113,7 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between py-2">
<dt>Last accessed</dt> <dt>Last accessed</dt>
<dd class="font-medium text-gray-900"> <dd class="font-medium text-gray-900">
{% if last_accessed %} {% if last_accessed %}
@@ -45,34 +125,64 @@
</div> </div>
</dl> </dl>
</div> </div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">Server details</h2>
<dl class="mt-4 space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<dt>Hostname</dt>
<dd class="font-medium text-gray-900">{{ server.hostname|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between">
<dt>IPv4</dt>
<dd class="font-medium text-gray-900">{{ server.ipv4|default:"—" }}</dd>
</div>
<div class="flex items-center justify-between">
<dt>IPv6</dt>
<dd class="font-medium text-gray-900">{{ server.ipv6|default:"—" }}</dd>
</div>
</dl>
</div>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Logs</h2>
<span class="text-xs font-semibold text-gray-500">Placeholder</span>
</div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-600">
Logs will appear here once collection is enabled for this server.
</div>
</section> </section>
</div> </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 %} {% 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 %}

View File

@@ -7,4 +7,7 @@ app_name = "servers"
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("<int:server_id>/", views.detail, name="detail"), path("<int:server_id>/", views.detail, name="detail"),
path("<int:server_id>/audit/", views.audit, name="audit"),
path("<int:server_id>/shell/", views.shell, name="shell"),
path("<int:server_id>/settings/", views.settings, name="settings"),
] ]

View File

@@ -1,28 +1,31 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import render from django.shortcuts import render
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, get_perms
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.servers.models import Server 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/") @login_required(login_url="/accounts/login/")
def dashboard(request): def dashboard(request):
now = timezone.now() now = timezone.now()
if request.user.has_perm("servers.view_server"): server_qs = get_objects_for_user(
server_qs = Server.objects.all() request.user,
else: "servers.view_server",
server_qs = get_objects_for_user( klass=Server,
request.user, accept_global_perms=False,
"servers.view_server", )
klass=Server,
accept_global_perms=False,
)
access_qs = ( access_qs = (
AccessRequest.objects.select_related("server") AccessRequest.objects.select_related("server")
@@ -44,14 +47,16 @@ def dashboard(request):
if expires_at is None or expires_at > current: if expires_at is None or expires_at > current:
expires_map[access.server_id] = expires_at expires_map[access.server_id] = expires_at
servers = [ servers = []
{ for server in server_qs:
"server": server, servers.append(
"expires_at": expires_map.get(server.id), {
"last_accessed": None, "server": server,
} "expires_at": expires_map.get(server.id),
for server in server_qs "last_accessed": None,
] "status": _build_server_status(server, now),
}
)
context = { context = {
"servers": servers, "servers": servers,
@@ -62,14 +67,10 @@ def dashboard(request):
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def detail(request, server_id: int): def detail(request, server_id: int):
now = timezone.now() now = timezone.now()
try: # Authorization is enforced via object-level permissions before we do
server = Server.objects.get(id=server_id) # any other server-specific work.
except Server.DoesNotExist: server = _get_server_or_404(request, server_id)
raise Http404("Server not found") can_shell = user_can_shell(request.user, server, now)
if not request.user.has_perm("servers.view_server", server) and not request.user.has_perm(
"servers.view_server"
):
raise Http404("Server not found")
access = ( access = (
AccessRequest.objects.filter( AccessRequest.objects.filter(
@@ -82,9 +83,148 @@ def detail(request, server_id: int):
.first() .first()
) )
account, system_username, certificate_key_id = _load_account_context(request, server)
context = { context = {
"server": server, "server": server,
"expires_at": access.expires_at if access else None, "expires_at": access.expires_at if access else None,
"last_accessed": 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) 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

@@ -19,6 +19,9 @@ 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
@@ -33,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
@@ -54,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,
@@ -123,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

View File

@@ -5,20 +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.core.validators import validate_ipv4_address, validate_ipv6_address from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.db import IntegrityError, models, transaction 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 django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from ninja import Body, Router, Schema from ninja import Body, Router, Schema
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):
@@ -80,6 +109,7 @@ class AgentHeartbeatIn(Schema):
host: Optional[str] = None host: Optional[str] = None
ipv4: Optional[str] = None ipv4: Optional[str] = None
ipv6: Optional[str] = None ipv6: Optional[str] = None
ping_ms: Optional[int] = None
def build_router() -> Router: def build_router() -> Router:
@@ -152,44 +182,67 @@ def build_router() -> Router:
"""Resolve the effective authorized_keys list for a server. """Resolve the effective authorized_keys list for a server.
Auth: required (admin/operator via API). Auth: required (admin/operator via API).
Permissions: requires view access to servers, keys, and access requests. Permissions: requires view access to servers and keys.
Behavior: combines approved access requests with active SSH keys to Behavior: uses server object permissions + active SSH keys to produce
produce the exact key list the agent should deploy to the server. the exact key list the agent should deploy to the server.
Rationale: this is the policy enforcement point for per-user access. Rationale: this is the policy enforcement point for per-user access.
""" """
require_perms( require_perms(
request, request,
"servers.view_server", "servers.view_server",
"keys.view_sshkey", "keys.view_sshkey",
"access.view_accessrequest",
)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
now = timezone.now()
access_qs = AccessRequest.objects.select_related("requester").filter(
server=server,
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,
) )
server = _get_server_or_404(server_id)
users = _resolve_access_users(server)
key_map = _key_map_for_users(users)
output: list[AuthorizedKeyOut] = []
for user in users:
for key in key_map.get(user.id, []):
output.append(
AuthorizedKeyOut(
user_id=user.id,
username=user.username,
email=user.email or "",
public_key=key.public_key,
fingerprint=key.fingerprint,
)
)
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 [ return [
AuthorizedKeyOut( AccountAccessOut(
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, system_username=render_system_username(user.username, user.id),
fingerprint=key.fingerprint, keys=[],
) )
for key in keys for user in users
] ]
@router.get("/servers/{server_id}/ssh-ca", auth=None)
@csrf_exempt
def ssh_ca(request: HttpRequest, server_id: int):
"""Return the active SSH user CA public key for agents.
Auth: mTLS expected at the edge (no session/JWT).
"""
_ = _get_server_or_404(server_id)
ca = get_active_ca()
if not ca.public_key:
raise HttpError(404, "SSH CA not configured")
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint}
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None) @router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
@csrf_exempt @csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)): def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
@@ -216,6 +269,8 @@ 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)
@@ -250,7 +305,7 @@ def build_router() -> Router:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Server not found") raise HttpError(404, "Server not found")
updates: dict[str, str] = {} updates: dict[str, str | int | datetime] = {}
host = (payload.host or "").strip()[:253] host = (payload.host or "").strip()[:253]
if host: if host:
try: try:
@@ -265,6 +320,10 @@ def build_router() -> Router:
ipv6 = _normalize_ip(payload.ipv6, 6) ipv6 = _normalize_ip(payload.ipv6, 6)
if ipv6 and server.ipv6 != ipv6: if ipv6 and server.ipv6 != ipv6:
updates["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: if updates:
for field, value in updates.items(): for field, value in updates.items():
setattr(server, field, value) setattr(server, field, value)
@@ -277,6 +336,62 @@ def build_router() -> Router:
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)

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):
@@ -63,7 +69,13 @@ def build_router() -> Router:
"key": et.key, "key": et.key,
"title": et.title, "title": et.title,
"description": et.description or "", "description": et.description or "",
"kind": et.kind,
"default_severity": et.default_severity, "default_severity": et.default_severity,
"endpoints": list(et.endpoints or []),
"ip_whitelist_enabled": bool(et.ip_whitelist_enabled),
"ip_whitelist": list(et.ip_whitelist or []),
"ip_blacklist_enabled": bool(et.ip_blacklist_enabled),
"ip_blacklist": list(et.ip_blacklist or []),
} }
for et in qs for et in qs
] ]

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))
@@ -131,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:
key.save() with transaction.atomic():
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)
@@ -153,6 +181,64 @@ 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.
@@ -179,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)
@@ -204,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

@@ -5,8 +5,8 @@ from typing import List, Optional
from django.http import HttpRequest from django.http import HttpRequest
from ninja import Router, Schema from ninja import Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user, get_perms
from apps.core.rbac import require_perms from apps.core.rbac import require_authenticated, require_perms
from apps.servers.models import Server from apps.servers.models import Server
@@ -32,20 +32,17 @@ def build_router() -> Router:
"""List servers the caller can view. """List servers the caller can view.
Auth: required. Auth: required.
Permissions: requires `servers.view_server` globally or per-object. Permissions: requires `servers.view_server` via object permissions.
Behavior: returns only servers the user can see via object perms. Behavior: returns only servers the user can see via object perms.
Rationale: drives the server dashboard and access-aware navigation. Rationale: drives the server dashboard and access-aware navigation.
""" """
require_perms(request, "servers.view_server") require_authenticated(request)
if request.user.has_perm("servers.view_server"): servers = get_objects_for_user(
servers = Server.objects.all() request.user,
else: "servers.view_server",
servers = get_objects_for_user( klass=Server,
request.user, accept_global_perms=False,
"servers.view_server", )
klass=Server,
accept_global_perms=False,
)
return [ return [
{ {
"id": s.id, "id": s.id,
@@ -64,18 +61,16 @@ def build_router() -> Router:
"""Get a server record by id. """Get a server record by id.
Auth: required. Auth: required.
Permissions: requires `servers.view_server` globally or per-object. Permissions: requires `servers.view_server` via object permissions.
Rationale: used by server detail views and API clients inspecting Rationale: used by server detail views and API clients inspecting
server metadata (hostname/IPs populated by the agent). server metadata (hostname/IPs populated by the agent).
""" """
require_perms(request, "servers.view_server") 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 not request.user.has_perm("servers.view_server", server) and not request.user.has_perm( if "view_server" not in get_perms(request.user, server):
"servers.view_server"
):
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
return { return {
"id": server.id, "id": server.id,

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)),
}
)

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

@@ -6,6 +6,7 @@ from django.urls import reverse_lazy
from django.templatetags.static import static 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
@@ -20,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
@@ -34,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",
@@ -93,10 +96,22 @@ 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_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL) CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
@@ -168,6 +183,7 @@ UNFOLD = {
"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,
@@ -240,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",
@@ -261,4 +278,5 @@ 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

@@ -16,3 +16,6 @@ urlpatterns = [
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"), path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
path("", RedirectView.as_view(pattern_name="servers:dashboard", permanent=False)), path("", RedirectView.as_view(pattern_name="servers:dashboard", permanent=False)),
] ]
handler404 = "apps.core.views.disguised_not_found"

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

@@ -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();
});
})();

File diff suppressed because one or more lines are too long

View File

@@ -718,3 +718,17 @@ const renderCharts = () => {
changeDarkModeSettings(); changeDarkModeSettings();
}); });
}; };
function getCurrentTab() {
const fragment = window.location.hash?.replace('#', '');
if (!fragment) {
return null
}
if (!document.getElementById(`${fragment}-group`)) {
return null;
}
return fragment
}

209
app/static/vendor/xterm/xterm.css vendored Normal file
View File

@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

2
app/static/vendor/xterm/xterm.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -8,42 +8,107 @@
<title>{% block title %}Keywarden{% endblock %}</title> <title>{% block title %}Keywarden{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}"> <link rel="icon" type="image/svg+xml" href="{% static 'branding/keywarden-favicon.svg' %}">
<link rel="icon" type="image/png" href="{% static 'ninja/favicon.png' %}"> <link rel="icon" type="image/png" href="{% static 'ninja/favicon.png' %}">
<meta name="theme-color" content="#0b1f24"> <meta name="theme-color" content="#0f172a">
<meta property="og:title" content="Keywarden"> <meta property="og:title" content="Keywarden">
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
{% tailwind_css %} {% tailwind_css %}
{% block extra_head %}{% endblock %}
</head> </head>
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased"> <body class="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 text-slate-900 antialiased font-['Space_Grotesk']">
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<header class="border-b border-gray-200 bg-white"> {% if not is_popout %}
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <header class="border-b border-gray-200 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/75">
<div class="flex h-20 items-center justify-between"> <nav class="mx-auto max-w-screen-xl px-4 py-3 lg:px-6">
<a href="/" class="inline-flex items-center gap-2"> <div class="flex flex-wrap items-center justify-between">
<img src="{% static 'branding/keywarden-favicon.svg' %}" alt="Keywarden logo" class="h-10 w-10"> <a href="/" class="flex items-center gap-3">
<span class="text-2xl font-semibold tracking-tight">Keywarden</span> <span class="flex h-11 w-11 items-center justify-center rounded-2xl bg-white p-1 shadow-sm ring-1 ring-blue-100">
</a> <img src="{% static 'branding/keywarden-favicon.svg' %}" alt="Keywarden logo" class="h-8 w-8">
<nav class="flex items-center gap-4"> </span>
{% if request.user.is_authenticated %} <span class="flex flex-col">
<a href="{% url 'servers:dashboard' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Servers</a> <span class="text-xl font-semibold leading-tight tracking-tight">Keywarden</span>
<a href="{% url 'accounts:profile' %}" class="text-sm font-medium text-gray-700 hover:text-purple-700">Profile</a> <span class="text-xs text-gray-500">Access control vault</span>
<a href="{% url 'accounts:logout' %}" class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">Logout</a> </span>
{% else %} </a>
<a href="{% url 'accounts:login' %}" class="inline-flex items-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-purple-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-600">Login</a> <div class="flex items-center gap-3 md:order-2">
{% endif %} {% if request.user.is_authenticated %}
</nav> <a
</div> href="{% url 'accounts:logout' %}"
</div> 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"
</header> >
Logout
</a>
{% else %}
<a
href="{% url 'accounts:login' %}"
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"
>
Login
</a>
{% endif %}
<button
data-collapse-toggle="navbar-default"
type="button"
class="inline-flex items-center rounded-lg p-2 text-sm text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 md:hidden"
aria-controls="navbar-default"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<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="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<div class="hidden w-full md:order-1 md:block md:w-auto" id="navbar-default">
<ul class="mt-4 flex flex-col rounded-lg border border-gray-100 bg-gray-50 p-4 text-sm font-medium text-gray-700 md:mt-0 md:flex-row md:space-x-6 md:border-0 md:bg-transparent md:p-0">
{% if request.user.is_authenticated %}
<li>
<a
href="{% url 'servers:dashboard' %}"
class="block rounded px-3 py-2 hover:bg-gray-100 md:px-0 md:py-0 md:hover:bg-transparent md:hover:text-blue-700"
>
Servers
</a>
</li>
<li>
<a
href="{% url 'accounts:profile' %}"
class="block rounded px-3 py-2 hover:bg-gray-100 md:px-0 md:py-0 md:hover:bg-transparent md:hover:text-blue-700"
>
Profile
</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
</header>
{% endif %}
<main class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 flex-1 w-full"> <main class="mx-auto w-full max-w-screen-xl flex-1 px-4 py-8 sm:px-6 lg:px-8">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="border-t border-gray-200 bg-white"> {% if not is_popout %}
<div class="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-500 sm:px-6 lg:px-8"> <footer class="border-t border-gray-200 bg-white">
<a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://git.ntbx.io/boris/keywarden">Keywarden</a> | <a class="underline text-blue-600 hover:text-blue-800 visited:text-purple-600" href="https://ntbx.io">George Wilkinson</a> (2025) <div class="mx-auto flex w-full max-w-screen-xl flex-col gap-2 px-4 py-6 text-sm text-gray-500 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
</div> <div class="flex items-center gap-2">
</footer> <span class="font-medium text-gray-700">Keywarden</span>
<span class="text-gray-300"></span>
<span>Secure access manager</span>
</div>
<div class="flex items-center gap-3">
<a class="font-medium text-blue-700 hover:underline" href="https://git.ntbx.io/boris/keywarden">Repository</a>
<span class="text-gray-300"></span>
<a class="font-medium text-blue-700 hover:underline" href="https://ntbx.io">George Wilkinson</a>
</div>
</div>
</footer>
{% endif %}
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/flowbite@4.0.1/dist/flowbite.min.js"></script>
</body> </body>
</html> </html>

View File

@@ -33,6 +33,21 @@ http {
default $http_x_forwarded_for; default $http_x_forwarded_for;
} }
# Basic connection and request shaping to reduce abusive traffic.
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
limit_req_zone $binary_remote_addr zone=perip_req:10m rate=20r/s;
map $request_uri $is_api_like {
default 0;
~^/api/ 1;
}
client_body_timeout 15s;
client_header_timeout 15s;
send_timeout 30s;
keepalive_timeout 30s;
large_client_header_buffers 4 16k;
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
@@ -52,15 +67,45 @@ http {
include options-https-headers.conf; include options-https-headers.conf;
client_max_body_size 50M; client_max_body_size 50M;
limit_conn perip_conn 30;
limit_req zone=perip_req burst=40 nodelay;
# Never serve hidden files or common secret/config artifacts.
location ~ /\.(?!well-known) {
return 404;
}
location ~* /(\\.git|\\.env|composer\\.(json|lock)|package(-lock)?\\.json|yarn\\.lock)$ {
return 404;
}
location / { location / {
proxy_intercept_errors on;
error_page 404 = @masked_404;
error_page 401 = @masked_401;
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $forwarded_for; proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-Host $host;
include options-https-headers.conf; include options-https-headers.conf;
} }
location @masked_404 {
if ($is_api_like) {
return 401;
}
return 302 /;
}
location @masked_401 {
if ($is_api_like) {
return 404;
}
return 302 /;
}
} }
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;

View File

@@ -33,6 +33,21 @@ http {
default $http_x_forwarded_for; default $http_x_forwarded_for;
} }
# Basic connection and request shaping to reduce abusive traffic.
limit_conn_zone $binary_remote_addr zone=perip_conn:10m;
limit_req_zone $binary_remote_addr zone=perip_req:10m rate=20r/s;
map $request_uri $is_api_like {
default 0;
~^/api/ 1;
}
client_body_timeout 15s;
client_header_timeout 15s;
send_timeout 30s;
keepalive_timeout 30s;
large_client_header_buffers 4 16k;
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@@ -55,15 +70,59 @@ http {
include options-https-headers.conf; include options-https-headers.conf;
client_max_body_size 50M; client_max_body_size 50M;
limit_conn perip_conn 30;
limit_req zone=perip_req burst=40 nodelay;
# Never serve hidden files or common secret/config artifacts.
location ~ /\.(?!well-known) {
return 404;
}
location ~* /(\\.git|\\.env|composer\\.(json|lock)|package(-lock)?\\.json|yarn\\.lock)$ {
return 404;
}
location /ws/ {
proxy_pass http://127.0.0.1:8001;
include options-https-headers.conf;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}
location / { location / {
proxy_intercept_errors on;
error_page 404 = @masked_404;
error_page 401 = @masked_401;
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
include options-https-headers.conf; include options-https-headers.conf;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $forwarded_for; proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 1h;
} }
# I don't like this, but it confuses probes and crawlers if public facing.
location @masked_404 {
if ($is_api_like) {
return 401;
}
return 302 /;
}
location @masked_401 {
if ($is_api_like) {
return 401;
}
return 302 /;
}
} }
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;

View File

@@ -8,6 +8,8 @@ mozilla-django-oidc>=5.0.2
django-unfold>=0.76.0 django-unfold>=0.76.0
django-tailwind==4.4.0 django-tailwind==4.4.0
django-guardian>=3.2.0 django-guardian>=3.2.0
channels>=4.0.0
daphne>=4.0.0
argon2-cffi>=25.1.0 argon2-cffi>=25.1.0
psycopg2-binary>=2.9.11 psycopg2-binary>=2.9.11
gunicorn>=24.1.0 gunicorn>=24.1.0

View File

@@ -17,6 +17,20 @@ stopsignal=TERM
stopasgroup=true stopasgroup=true
killasgroup=true killasgroup=true
[program:daphne]
command=/app/scripts/daphne.sh
directory=/app
user=djangouser
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopsignal=TERM
stopasgroup=true
killasgroup=true
[program:nginx] [program:nginx]
command=/usr/sbin/nginx -g "daemon off;" command=/usr/sbin/nginx -g "daemon off;"
autostart=true autostart=true