From 3a843354b6bb0a36e90887bb5d932157abb4afa0 Mon Sep 17 00:00:00 2001 From: "Patrick Asmus (scriptos)" Date: Sun, 5 Apr 2026 22:46:09 +0200 Subject: [PATCH 1/8] docs: add dashboard screenshot to README --- README.md | 4 +++- assets/img/dashboard.png | Bin 0 -> 53873 bytes 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 assets/img/dashboard.png diff --git a/README.md b/README.md index 19550fc..0b98685 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ **Keywarden** is a self-hosted web application for centralized SSH key management and deployment. It lets you generate, store, and deploy SSH keys to Linux servers from a single web interface — with full audit logging, role-based access control, and automated temporary access scheduling. ---- + +![Keywarden Dashboard](assets/img/dashboard.png) + ## ⚠️ Alpha Software — Important Notice diff --git a/assets/img/dashboard.png b/assets/img/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..c8cb417b77b31bf20471f98eb006646eda4c5975 GIT binary patch literal 53873 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 ae?|1=0DTbAduBCl&!r0{`Xzdfzx^*wr_dAt literal 0 HcmV?d00001 From 8b9de9e83d4afcf7e44a57179d21b1d41a091d31 Mon Sep 17 00:00:00 2001 From: "Patrick Asmus (scriptos)" Date: Mon, 6 Apr 2026 00:17:03 +0200 Subject: [PATCH 2/8] feat: add Bastillion-style SSH key enforcement worker --- README.md | 1 + cmd/keywarden/main.go | 8 +- docs/README.md | 1 + docs/admin-guide.md | 8 + docs/architecture.md | 4 +- docs/security.md | 55 +++ internal/audit/audit.go | 7 + internal/deploy/deploy.go | 107 +++++ internal/handlers/handlers.go | 51 ++- internal/worker/worker.go | 672 ++++++++++++++++++++++++++++++ web/templates/admin_settings.html | 81 ++++ 11 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 internal/worker/worker.go diff --git a/README.md b/README.md index 0b98685..677144c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - **Temporary Access** — Schedule time-limited access with automatic expiry (key removal, user disable, or user deletion) - **Three-Tier Roles** — Owner, Admin, and User with distinct permissions - **User Invitations** — Invite users via secure email links +- **Key Enforcement** — Bastillion-style enforced key management: automatically detect and remove unauthorized SSH keys from servers - **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users - **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection - **Audit Log** — Every action tracked with user, IP, timestamp, and details diff --git a/cmd/keywarden/main.go b/cmd/keywarden/main.go index e97ac03..2a8c327 100644 --- a/cmd/keywarden/main.go +++ b/cmd/keywarden/main.go @@ -24,6 +24,7 @@ import ( "git.techniverse.net/scriptos/keywarden/internal/mail" "git.techniverse.net/scriptos/keywarden/internal/security" "git.techniverse.net/scriptos/keywarden/internal/servers" + "git.techniverse.net/scriptos/keywarden/internal/worker" "git.techniverse.net/scriptos/keywarden/web" ) @@ -76,6 +77,7 @@ func main() { deploySvc := deploy.NewService(db) auditSvc := audit.NewService(db) cronSvc := cron.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc) + workerSvc := worker.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc) mailSvc := mail.NewService(cfg) // Create default owner if no users exist (password is auto-generated) @@ -116,7 +118,7 @@ func main() { } // Setup HTTP handlers - handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL) + handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -142,6 +144,10 @@ func main() { cronSvc.Start() defer cronSvc.Stop() + // Start key enforcement worker + workerSvc.Start() + defer workerSvc.Stop() + // Start server addr := ":" + cfg.Port logging.Info("Server starting on http://0.0.0.0%s", addr) diff --git a/docs/README.md b/docs/README.md index 9da4bd0..442d628 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ Keywarden provides a clean web UI to generate, import, and securely store SSH ke - **Temporary Access (Cron Jobs)** — Schedule time-limited access with automatic key removal, user disabling, or user deletion on expiry - **Three-Tier Role System** — Owner, Admin, and User roles with clear permission boundaries - **User Invitations** — Invite new users via secure email links with self-service password setup +- **Key Enforcement** — Bastillion-style enforced key management: detect and remove unauthorized SSH keys automatically - **TOTP Two-Factor Authentication** — Optional or enforced MFA for all users - **Password Policies** — Configurable complexity requirements with account lockout - **Email Notifications** — Login alerts and invitation emails via SMTP diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 0619628..5d32637 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -234,6 +234,14 @@ Navigate to **Admin Settings** (owner only) to configure: - **Account Lockout** — Number of failed attempts before lockout and lockout duration - **MFA Enforcement** — Require all users to enable TOTP MFA +### Key Enforcement + +- **Enforcement Mode** — Disabled (default), Monitor (log only), or Enforce (auto-remove unauthorized keys) +- **Check Interval** — How often the worker scans servers (1–1440 minutes, default: 15) +- **Run Now** — Trigger an immediate enforcement check + +See [Security — Key Enforcement](security.md#key-enforcement-bastillion-style) for details. + ### Master Key - View the system master key's public key and fingerprint diff --git a/docs/architecture.md b/docs/architecture.md index 5e9b535..fb1a78f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ internal/ security/ ← CSRF, security headers, rate limiting, proxy detection servers/ ← Server and server group management, access assignments sshutil/ ← SSH key generation (RSA, Ed25519, Ed448) + worker/ ← Background key enforcement worker (Bastillion-style) web/ embed.go ← Go embed directives for templates and static files static/ ← CSS, JS, fonts (Tabler UI framework) @@ -59,7 +60,8 @@ web/ 10. **Start session cleanup** goroutine (removes expired sessions every minute) 11. **Apply middleware chain**: request logger → security headers → rate limiting → size limiting → CSRF 12. **Start cron scheduler** (checks for pending jobs every 30 seconds) -13. **Start HTTP server** +13. **Start key enforcement worker** (if enabled in Admin Settings) +14. **Start HTTP server** ## Database Design diff --git a/docs/security.md b/docs/security.md index 88daad6..ef943c4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -209,3 +209,58 @@ When deploying keys to servers, Keywarden: 8. **Network isolation**: Restrict access to Keywarden and managed servers to trusted networks 9. **Keep the encryption key safe**: Back up `KEYWARDEN_ENCRYPTION_KEY` securely — losing it means losing all private keys 10. **Monitor the audit log**: Review login activity and deployment actions regularly +11. **Enable key enforcement**: Use enforce mode to ensure only Keywarden-managed keys exist on your servers + +## Key Enforcement (Bastillion-Style) + +Keywarden includes an enforced key management feature inspired by [Bastillion](https://www.bastillion.io/). When enabled, a background worker periodically connects to all managed servers and ensures that only authorized SSH keys are present in `authorized_keys` files. + +### How It Works + +1. The enforcement worker runs at a configurable interval (default: 15 minutes) +2. For each managed server and system user, it reads the current `authorized_keys` +3. It compares the keys against the **desired state** derived from: + - All active access assignments (desired_state = "present") + - All active cron jobs (temporary access that has not yet expired) + - All direct key deployments (via the Deploy page) + - The system master key (always authorized) +4. Unauthorized keys (not managed by Keywarden) are detected +5. Depending on the mode, unauthorized keys are either logged or removed + +### Modes + +| Mode | Behavior | +|---|---| +| **Disabled** | No enforcement checks (default) | +| **Monitor** | Detects unauthorized keys and logs them in the audit log, but does not remove them | +| **Enforce** | Detects unauthorized keys and **removes them automatically**, replacing `authorized_keys` with only the authorized set | + +### Configuration + +Key enforcement is configured in **Admin Settings → Key Enforcement**: + +- **Enforcement Mode**: Disabled / Monitor / Enforce +- **Check Interval**: How often the worker checks servers (1–1440 minutes) +- **Run Now**: Trigger an immediate enforcement check + +### Audit Trail + +All enforcement actions are recorded in the audit log: + +| Action | Description | +|---|---| +| `enforcement_run` | An enforcement cycle completed (with summary) | +| `enforcement_drift` | Unauthorized keys detected on a server | +| `enforcement_applied` | Unauthorized keys were removed from a server | +| `enforcement_failed` | An enforcement action failed (connection error, etc.) | +| `enforcement_settings_changed` | Enforcement settings were modified | + +### Important Notes + +- The system master key is **always** considered authorized and will never be removed +- Enforcement covers all system users that have active access assignments, cron jobs, or direct deployments in Keywarden +- The server's admin user (used for SSH connections) is always checked +- Enforcement requires the system master key to be deployed on target servers +- In **enforce** mode, `authorized_keys` is atomically replaced (write to temp file, then move) +- Manual runs can be triggered from the Admin Settings page + diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 0710d9c..c49262d 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -107,6 +107,13 @@ const ( ActionInvitationSendFailed = "invitation_send_failed" ActionInvitationAccepted = "invitation_accepted" ActionInvitationFailed = "invitation_failed" + + // Key Enforcement + ActionEnforcementRun = "enforcement_run" + ActionEnforcementDrift = "enforcement_drift" + ActionEnforcementApplied = "enforcement_applied" + ActionEnforcementFailed = "enforcement_failed" + ActionEnforcementSettings = "enforcement_settings_changed" ) // AuditEntry extends AuditLog with the username for display diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index f01415e..cbb31ab 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -655,3 +655,110 @@ func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error) } return deployments, nil } + +// ReadAuthorizedKeys reads the current authorized_keys for a system user on a remote server. +// Returns the list of key lines (non-empty, non-comment lines). +func (s *Service) ReadAuthorizedKeys(server *models.Server, authPrivateKey []byte, systemUser string) ([]string, error) { + signer, err := ssh.ParsePrivateKey(authPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse authentication key: %w", err) + } + + config := &ssh.ClientConfig{ + User: server.Username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port)) + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("failed to connect to server: %w", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + defer session.Close() + + homeDir := fmt.Sprintf("/home/%s", systemUser) + if systemUser == "root" { + homeDir = "/root" + } + + cmd := fmt.Sprintf(`cat %s/.ssh/authorized_keys 2>/dev/null || echo ""`, homeDir) + output, err := session.Output(cmd) + if err != nil { + return nil, fmt.Errorf("failed to read authorized_keys: %w", err) + } + + var keys []string + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + keys = append(keys, line) + } + } + return keys, nil +} + +// WriteAuthorizedKeys replaces the entire authorized_keys file for a system user on a remote server +// with the provided set of keys. This is the enforcement function. +func (s *Service) WriteAuthorizedKeys(server *models.Server, authPrivateKey []byte, systemUser string, authorizedKeys []string) error { + signer, err := ssh.ParsePrivateKey(authPrivateKey) + if err != nil { + return fmt.Errorf("failed to parse authentication key: %w", err) + } + + config := &ssh.ClientConfig{ + User: server.Username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port)) + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return fmt.Errorf("failed to connect to server: %w", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + defer session.Close() + + homeDir := fmt.Sprintf("/home/%s", systemUser) + if systemUser == "root" { + homeDir = "/root" + } + + // Build the authorized_keys content + content := strings.Join(authorizedKeys, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + // Use printf to write the content to avoid shell interpretation issues + // First write to a temp file, then atomically move it + escapedContent := strings.ReplaceAll(content, "'", "'\\''") + cmd := fmt.Sprintf( + `mkdir -p %s/.ssh && chmod 700 %s/.ssh && printf '%%s' '%s' > %s/.ssh/authorized_keys.tmp && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown '%s':'%s' %s/.ssh/authorized_keys`, + homeDir, homeDir, escapedContent, homeDir, homeDir, homeDir, homeDir, systemUser, systemUser, homeDir, + ) + + if err := session.Run(cmd); err != nil { + return fmt.Errorf("failed to write authorized_keys: %w", err) + } + + return nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 0246c6a..f655cb6 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -39,6 +39,7 @@ import ( "git.techniverse.net/scriptos/keywarden/internal/models" "git.techniverse.net/scriptos/keywarden/internal/security" "git.techniverse.net/scriptos/keywarden/internal/servers" + "git.techniverse.net/scriptos/keywarden/internal/worker" ) // sessionData holds session metadata for timeout tracking @@ -56,6 +57,7 @@ type Handler struct { deploy *deploy.Service audit *audit.Service cron *cron.Service + worker *worker.Service mail *mail.Service db *database.DB // direct database access for backup/restore templates map[string]*template.Template @@ -169,6 +171,9 @@ type PageData struct { // System Information SystemInfo *SystemInfo + + // Key Enforcement + EnforcementStatus map[string]string } // SystemInfo holds runtime system information for the settings page @@ -242,7 +247,7 @@ func formatUptime(start time.Time) string { } // New creates a new Handler -func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string) *Handler { +func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string) *Handler { // Create sub-FS so /static/css/... maps to static/css/... in embed staticSub, err := fs.Sub(staticFS, "static") if err != nil { @@ -262,6 +267,7 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi deploy: deploySvc, audit: auditSvc, cron: cronSvc, + worker: workerSvc, mail: mailSvc, db: db, sessions: make(map[string]*sessionData), @@ -432,6 +438,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/masterkey/regenerate", h.requireOwner(h.handleMasterKeyRegenerate)) mux.HandleFunc("/admin/backup/export", h.requireOwner(h.handleBackupExport)) mux.HandleFunc("/admin/backup/import", h.requireOwner(h.handleBackupImport)) + mux.HandleFunc("/admin/enforcement/run", h.requireOwner(h.handleEnforcementRunNow)) } // handleAPIHealth returns a JSON health status (no auth required). @@ -2993,6 +3000,7 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) { EmailEnabled: h.mail.IsEnabled(), MasterKeyPublic: masterPub, MasterKeyFingerprint: masterFP, + EnforcementStatus: h.worker.GetStatus(), } // Check for flash message from query parameters (e.g. after backup restore) @@ -3047,6 +3055,31 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) { if len(changed) > 0 { h.audit.Log(userID, audit.ActionPasswordPolicyChanged, fmt.Sprintf("Security settings updated: %s", strings.Join(changed, ", ")), clientIP(r)) } + case "enforcement_settings": + // Key enforcement settings + batch := make(map[string]string) + enforceMode := r.FormValue("enforce_mode") + if enforceMode == "" { + enforceMode = "disabled" + } + batch["enforce_mode"] = enforceMode + changed = append(changed, "enforce_mode="+enforceMode) + + enforceInterval := r.FormValue("enforce_interval") + if enforceInterval == "" { + enforceInterval = "15" + } + batch["enforce_interval"] = enforceInterval + changed = append(changed, "enforce_interval="+enforceInterval) + + if err := h.auth.SetSettingsBatch(batch); err != nil { + logging.Error("Failed to save enforcement settings: %v", err) + http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to save enforcement settings: "+err.Error()), http.StatusSeeOther) + return + } + if len(changed) > 0 { + h.audit.Log(userID, audit.ActionEnforcementSettings, fmt.Sprintf("Enforcement settings updated: %s", strings.Join(changed, ", ")), clientIP(r)) + } default: // Application settings (existing behavior) batch := make(map[string]string) @@ -3124,6 +3157,22 @@ func (h *Handler) handleMasterKeyRegenerate(w http.ResponseWriter, r *http.Reque http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("System master key successfully regenerated."), http.StatusSeeOther) } +// handleEnforcementRunNow triggers an immediate key enforcement run (owner only) +func (h *Handler) handleEnforcementRunNow(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) + return + } + + userID := h.getUserID(r) + logging.Info("Key enforcement manual run triggered by user_id=%d", userID) + h.audit.Log(userID, audit.ActionEnforcementRun, "Manual key enforcement run triggered", clientIP(r)) + + h.worker.RunNow() + + http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Key enforcement run started. Check the audit log for results."), http.StatusSeeOther) +} + // --- Cron Job Handlers --- // handleAPICronAssignments returns assignments for a given user as JSON (for AJAX). diff --git a/internal/worker/worker.go b/internal/worker/worker.go new file mode 100644 index 0000000..5e4fcee --- /dev/null +++ b/internal/worker/worker.go @@ -0,0 +1,672 @@ +// Keywarden - Centralized SSH Key Management and Deployment +// Copyright (C) 2026 Patrick Asmus (scriptos) +// SPDX-License-Identifier: AGPL-3.0-or-later + +package worker + +import ( + "fmt" + "strings" + "sync" + "time" + + "git.techniverse.net/scriptos/keywarden/internal/audit" + "git.techniverse.net/scriptos/keywarden/internal/database" + "git.techniverse.net/scriptos/keywarden/internal/deploy" + "git.techniverse.net/scriptos/keywarden/internal/keys" + "git.techniverse.net/scriptos/keywarden/internal/logging" + "git.techniverse.net/scriptos/keywarden/internal/models" + "git.techniverse.net/scriptos/keywarden/internal/servers" +) + +// Mode defines the enforcement behavior +const ( + ModeDisabled = "disabled" // no enforcement + ModeMonitor = "monitor" // detect unauthorized keys, log only + ModeEnforce = "enforce" // detect + remove unauthorized keys +) + +// DefaultInterval is the default enforcement check interval in minutes +const DefaultInterval = 15 + +// Service handles the background key enforcement worker +type Service struct { + db *database.DB + deploy *deploy.Service + keys *keys.Service + servers *servers.Service + audit *audit.Service + stopCh chan struct{} + wg sync.WaitGroup + mu sync.Mutex + running bool +} + +// NewService creates a new enforcement worker service +func NewService(db *database.DB, deploySvc *deploy.Service, keysSvc *keys.Service, serversSvc *servers.Service, auditSvc *audit.Service) *Service { + return &Service{ + db: db, + deploy: deploySvc, + keys: keysSvc, + servers: serversSvc, + audit: auditSvc, + stopCh: make(chan struct{}), + } +} + +// Start begins the enforcement worker loop +func (s *Service) Start() { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return + } + s.running = true + s.mu.Unlock() + + s.wg.Add(1) + go func() { + defer s.wg.Done() + // Check settings every 60 seconds to see if enforcement is enabled + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + var lastRun time.Time + + for { + select { + case <-ticker.C: + mode := s.getMode() + if mode == ModeDisabled { + continue + } + interval := s.getInterval() + if time.Since(lastRun) >= time.Duration(interval)*time.Minute { + s.runEnforcement(mode) + lastRun = time.Now() + } + case <-s.stopCh: + return + } + } + }() + logging.Info("Key enforcement worker started (checks settings every 60s)") +} + +// Stop gracefully stops the enforcement worker +func (s *Service) Stop() { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + s.running = false + s.mu.Unlock() + close(s.stopCh) + s.wg.Wait() +} + +// RunNow triggers an immediate enforcement run (e.g. from admin UI) +func (s *Service) RunNow() { + mode := s.getMode() + if mode == ModeDisabled { + logging.Warn("Key enforcement: manual run requested but enforcement is disabled") + return + } + go s.runEnforcement(mode) +} + +// getMode reads the enforcement mode from settings +func (s *Service) getMode() string { + var val string + err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_mode'`).Scan(&val) + if err != nil || val == "" { + return ModeDisabled + } + switch val { + case ModeMonitor, ModeEnforce: + return val + default: + return ModeDisabled + } +} + +// getInterval reads the enforcement interval from settings (in minutes) +func (s *Service) getInterval() int { + var val string + err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_interval'`).Scan(&val) + if err != nil || val == "" { + return DefaultInterval + } + var interval int + fmt.Sscanf(val, "%d", &interval) + if interval < 1 { + return DefaultInterval + } + return interval +} + +// runEnforcement performs one enforcement cycle across all managed servers +func (s *Service) runEnforcement(mode string) { + logging.Info("Key enforcement: starting run (mode=%s)", mode) + + // Get system master key + masterKeyPEM, err := s.keys.GetSystemMasterKeyPrivate() + if err != nil { + logging.Error("Key enforcement: cannot get system master key: %v", err) + return + } + masterKeyPub, err := s.keys.GetSystemMasterKeyPublic() + if err != nil { + logging.Error("Key enforcement: cannot get system master key public: %v", err) + return + } + + // Get all servers + allServers, err := s.servers.GetAllServers() + if err != nil { + logging.Error("Key enforcement: failed to get servers: %v", err) + return + } + + if len(allServers) == 0 { + logging.Debug("Key enforcement: no servers configured, skipping") + return + } + + // Build desired-state map: server_id -> system_user -> []public_key + desiredKeys := s.buildDesiredState(masterKeyPub) + + var totalChecked, totalUnauthorized, totalRemoved, totalErrors int + + for _, srv := range allServers { + server := srv + // For each server, determine which system users to check + usersToCheck := s.getSystemUsersForServer(server.ID) + // Always check the server's default admin user + if _, exists := usersToCheck[server.Username]; !exists { + usersToCheck[server.Username] = true + } + + for systemUser := range usersToCheck { + checked, unauthorized, removed, errs := s.enforceServer(&server, systemUser, masterKeyPEM, masterKeyPub, desiredKeys, mode) + totalChecked += checked + totalUnauthorized += unauthorized + totalRemoved += removed + totalErrors += errs + } + } + + // Log summary + summary := fmt.Sprintf("Key enforcement run completed (mode=%s): %d servers checked, %d unauthorized keys found, %d removed, %d errors", + mode, totalChecked, totalUnauthorized, totalRemoved, totalErrors) + logging.Info("%s", summary) + + if totalUnauthorized > 0 || totalErrors > 0 { + s.audit.Log(0, audit.ActionEnforcementRun, summary, "worker") + } + + // Store last run info in settings + s.setSetting("enforce_last_run", time.Now().UTC().Format(time.RFC3339)) + s.setSetting("enforce_last_result", summary) +} + +// buildDesiredState builds the complete desired-state map: +// +// server_id -> system_user -> []public_key +// +// Sources of truth (a key is "authorized" if it comes from any of these): +// 1. Access Assignments with desired_state = "present" +// 2. Active Cron Jobs (temporary access) whose keys haven't expired yet +// 3. Direct deployments (via /deploy page) tracked in key_deployments +// 4. The system master key (always authorized on every server+user) +func (s *Service) buildDesiredState(masterKeyPub string) map[int64]map[string][]string { + desired := make(map[int64]map[string][]string) + + // Helper to add a key to the desired state (with deduplication) + addKey := func(serverID int64, systemUser, pubKey string) { + if serverID == 0 || systemUser == "" || pubKey == "" { + return + } + if _, ok := desired[serverID]; !ok { + desired[serverID] = make(map[string][]string) + } + pubKey = strings.TrimSpace(pubKey) + for _, existing := range desired[serverID][systemUser] { + if existing == pubKey { + return + } + } + desired[serverID][systemUser] = append(desired[serverID][systemUser], pubKey) + } + + // --- Build key lookup: key_id -> public_key --- + allKeys, err := s.keys.GetAllKeys() + if err != nil { + logging.Error("Key enforcement: failed to get all keys: %v", err) + return desired + } + keyMap := make(map[int64]string) + for _, k := range allKeys { + keyMap[k.ID] = strings.TrimSpace(k.PublicKey) + } + + // --- Build server lookup: server_id -> Server --- + allSrvs, _ := s.servers.GetAllServers() + srvMap := make(map[int64]*models.Server) + for i := range allSrvs { + srvMap[allSrvs[i].ID] = &allSrvs[i] + } + + // --- 1) Access Assignments (desired_state = "present") --- + assignments, err := s.servers.GetAllAssignments() + if err != nil { + logging.Error("Key enforcement: failed to get assignments: %v", err) + } else { + for _, a := range assignments { + if a.DesiredState != "present" { + continue + } + pubKey := keyMap[a.SSHKeyID] + if pubKey == "" { + continue + } + if a.ServerID > 0 { + addKey(a.ServerID, a.SystemUser, pubKey) + } + if a.GroupID > 0 { + members, err := s.servers.GetGroupMembersGlobal(a.GroupID) + if err != nil { + logging.Warn("Key enforcement: failed to resolve group %d: %v", a.GroupID, err) + continue + } + for _, m := range members { + addKey(m.ID, a.SystemUser, pubKey) + } + } + } + logging.Debug("Key enforcement: loaded %d access assignments into desired state", len(assignments)) + } + + // --- 2) Active Cron Jobs (temporary access, not yet expired) --- + // A cron-deployed key is authorized if: + // - remove_after_min = 0 (permanent) AND the job has executed (last_run IS NOT NULL) + // - remove_after_min > 0 AND last_run + remove_after_min > NOW() (not yet expired) + cronCount := s.addCronJobKeys(addKey, keyMap, srvMap) + logging.Debug("Key enforcement: loaded %d active cron job deployments into desired state", cronCount) + + // --- 3) Direct deployments (via /deploy page) --- + // These are tracked in key_deployments. For each key+server pair, the latest + // successful deploy (not removal) authorizes the key for the server's admin user. + deployCount := s.addDirectDeployKeys(addKey, keyMap, srvMap) + logging.Debug("Key enforcement: loaded %d direct deployments into desired state", deployCount) + + // --- 4) System master key (always authorized everywhere) --- + masterPub := strings.TrimSpace(masterKeyPub) + for _, srv := range allSrvs { + // Master key on every server's admin user + addKey(srv.ID, srv.Username, masterPub) + // Master key on every system user that has desired keys + if users, ok := desired[srv.ID]; ok { + for sysUser := range users { + addKey(srv.ID, sysUser, masterPub) + } + } + } + + return desired +} + +// addCronJobKeys queries cron_jobs for active temporary deployments and adds +// their keys to the desired state. Returns the number of active cron deployments found. +func (s *Service) addCronJobKeys(addKey func(int64, string, string), keyMap map[int64]string, srvMap map[int64]*models.Server) int { + // Query cron jobs whose deployed keys should still be on the server: + // - Job has executed at least once (last_run IS NOT NULL) + // - Either permanent (remove_after_min = 0) or not yet expired + // - Job status indicates it has executed (not just created) + rows, err := s.db.Query( + `SELECT cj.ssh_key_id, cj.server_id, cj.group_id, cj.system_user + FROM cron_jobs cj + WHERE cj.last_run IS NOT NULL + AND cj.status IN ('done', 'active', 'running') + AND ( + cj.remove_after_min = 0 + OR datetime(cj.last_run, '+' || cj.remove_after_min || ' minutes') > datetime('now') + )`) + if err != nil { + logging.Warn("Key enforcement: failed to query active cron jobs: %v", err) + return 0 + } + defer rows.Close() + + var count int + for rows.Next() { + var keyID, serverID, groupID int64 + var systemUser string + if err := rows.Scan(&keyID, &serverID, &groupID, &systemUser); err != nil { + continue + } + pubKey := keyMap[keyID] + if pubKey == "" { + continue + } + + if serverID > 0 { + if systemUser != "" { + addKey(serverID, systemUser, pubKey) + } else if srv, ok := srvMap[serverID]; ok { + // No system user specified → deployed to server's admin user + addKey(serverID, srv.Username, pubKey) + } + count++ + } + if groupID > 0 { + members, err := s.servers.GetGroupMembersGlobal(groupID) + if err != nil { + continue + } + for _, m := range members { + if systemUser != "" { + addKey(m.ID, systemUser, pubKey) + } else { + addKey(m.ID, m.Username, pubKey) + } + } + count++ + } + } + return count +} + +// addDirectDeployKeys queries key_deployments for successful direct deployments +// (via /deploy page) and adds their keys to the desired state. +// For each key+server pair, the most recent entry determines if the key is still deployed. +// Direct deploys always target the server's configured admin user. +func (s *Service) addDirectDeployKeys(addKey func(int64, string, string), keyMap map[int64]string, srvMap map[int64]*models.Server) int { + // Get the latest deployment status for each key+server combination. + // A key is considered deployed if the latest entry contains "deployed" (not "removed"). + rows, err := s.db.Query( + `SELECT kd.ssh_key_id, kd.server_id, kd.message + FROM key_deployments kd + INNER JOIN ( + SELECT ssh_key_id, server_id, MAX(id) as max_id + FROM key_deployments + WHERE status = 'success' + GROUP BY ssh_key_id, server_id + ) latest ON kd.id = latest.max_id + WHERE kd.message LIKE '%deployed%'`) + if err != nil { + logging.Warn("Key enforcement: failed to query direct deployments: %v", err) + return 0 + } + defer rows.Close() + + var count int + for rows.Next() { + var keyID, serverID int64 + var message string + if err := rows.Scan(&keyID, &serverID, &message); err != nil { + continue + } + pubKey := keyMap[keyID] + if pubKey == "" { + continue + } + srv, ok := srvMap[serverID] + if !ok { + continue + } + + // Determine the system user from the deployment message + // DeployKeyToUser logs: "key deployed to user 'xxx'" + // DeployKey logs: "key deployed successfully" (→ server's admin user) + systemUser := srv.Username + if idx := strings.Index(message, "to user '"); idx >= 0 { + rest := message[idx+len("to user '"):] + if endIdx := strings.Index(rest, "'"); endIdx >= 0 { + systemUser = rest[:endIdx] + } + } + + addKey(serverID, systemUser, pubKey) + count++ + } + return count +} + +// getSystemUsersForServer returns all system users that should be checked on a server. +// This includes users from: +// 1. Access Assignments (direct + group) +// 2. Active Cron Jobs (direct + group) +func (s *Service) getSystemUsersForServer(serverID int64) map[string]bool { + users := make(map[string]bool) + + // --- 1a) Direct access assignments --- + rows, err := s.db.Query( + `SELECT DISTINCT system_user FROM access_assignments WHERE server_id = ? AND desired_state = 'present'`, serverID) + if err == nil { + for rows.Next() { + var u string + if rows.Scan(&u) == nil && u != "" { + users[u] = true + } + } + rows.Close() + } + + // --- 1b) Group access assignments --- + groupRows, err := s.db.Query( + `SELECT DISTINCT a.system_user FROM access_assignments a + JOIN server_group_members sgm ON a.group_id = sgm.group_id + WHERE sgm.server_id = ? AND a.desired_state = 'present' AND a.group_id > 0`, serverID) + if err == nil { + for groupRows.Next() { + var u string + if groupRows.Scan(&u) == nil && u != "" { + users[u] = true + } + } + groupRows.Close() + } + + // --- 2a) Direct cron jobs (active temporary access) --- + cronRows, err := s.db.Query( + `SELECT DISTINCT cj.system_user FROM cron_jobs cj + WHERE cj.server_id = ? + AND cj.last_run IS NOT NULL + AND cj.status IN ('done', 'active', 'running') + AND cj.system_user != '' + AND ( + cj.remove_after_min = 0 + OR datetime(cj.last_run, '+' || cj.remove_after_min || ' minutes') > datetime('now') + )`, serverID) + if err == nil { + for cronRows.Next() { + var u string + if cronRows.Scan(&u) == nil && u != "" { + users[u] = true + } + } + cronRows.Close() + } + + // --- 2b) Group cron jobs --- + cronGroupRows, err := s.db.Query( + `SELECT DISTINCT cj.system_user FROM cron_jobs cj + JOIN server_group_members sgm ON cj.group_id = sgm.group_id + WHERE sgm.server_id = ? + AND cj.last_run IS NOT NULL + AND cj.status IN ('done', 'active', 'running') + AND cj.system_user != '' + AND cj.group_id > 0 + AND ( + cj.remove_after_min = 0 + OR datetime(cj.last_run, '+' || cj.remove_after_min || ' minutes') > datetime('now') + )`, serverID) + if err == nil { + for cronGroupRows.Next() { + var u string + if cronGroupRows.Scan(&u) == nil && u != "" { + users[u] = true + } + } + cronGroupRows.Close() + } + + return users +} + +// enforceServer checks and optionally enforces key state for one server+user combination +func (s *Service) enforceServer(server *models.Server, systemUser string, masterKeyPEM []byte, masterKeyPub string, desiredKeys map[int64]map[string][]string, mode string) (checked, unauthorized, removed, errors int) { + checked = 1 + + // Read current authorized_keys from the server + currentKeys, err := s.deploy.ReadAuthorizedKeys(server, masterKeyPEM, systemUser) + if err != nil { + logging.Warn("Key enforcement: failed to read keys from %s@%s:%d (user=%s): %v", + server.Username, server.Hostname, server.Port, systemUser, err) + errors = 1 + return + } + + // Get desired keys for this server+user + var desired []string + if serverUsers, ok := desiredKeys[server.ID]; ok { + if keys, ok := serverUsers[systemUser]; ok { + desired = keys + } + } + + // Always include the master key + masterPub := strings.TrimSpace(masterKeyPub) + hasMaster := false + for _, k := range desired { + if k == masterPub { + hasMaster = true + break + } + } + if !hasMaster { + desired = append(desired, masterPub) + } + + // Build set of desired key fingerprints/content for comparison + desiredSet := make(map[string]bool) + for _, k := range desired { + desiredSet[normalizeKey(k)] = true + } + + // Find unauthorized keys + var unauthorizedKeys []string + for _, currentKey := range currentKeys { + normalized := normalizeKey(currentKey) + if normalized == "" { + continue + } + if !desiredSet[normalized] { + unauthorizedKeys = append(unauthorizedKeys, currentKey) + } + } + + unauthorized = len(unauthorizedKeys) + + if unauthorized == 0 { + logging.Debug("Key enforcement: %s@%s (user=%s): all %d keys authorized", + server.Username, server.Hostname, systemUser, len(currentKeys)) + return + } + + // Log the unauthorized keys + keySnippets := make([]string, 0, len(unauthorizedKeys)) + for _, k := range unauthorizedKeys { + snippet := k + if len(snippet) > 80 { + snippet = snippet[:80] + "..." + } + keySnippets = append(keySnippets, snippet) + } + + detail := fmt.Sprintf("Server %s (%s:%d), user '%s': %d unauthorized key(s) found: %s", + server.Name, server.Hostname, server.Port, systemUser, + unauthorized, strings.Join(keySnippets, "; ")) + + if mode == ModeMonitor { + logging.Warn("Key enforcement [MONITOR]: %s", detail) + s.audit.Log(0, audit.ActionEnforcementDrift, detail, "worker") + return + } + + // Mode: enforce — replace authorized_keys with only desired keys + logging.Warn("Key enforcement [ENFORCE]: %s — removing unauthorized keys", detail) + s.audit.Log(0, audit.ActionEnforcementDrift, detail, "worker") + + if err := s.deploy.WriteAuthorizedKeys(server, masterKeyPEM, systemUser, desired); err != nil { + logging.Error("Key enforcement: failed to write authorized_keys for %s@%s (user=%s): %v", + server.Username, server.Hostname, systemUser, err) + s.audit.Log(0, audit.ActionEnforcementFailed, + fmt.Sprintf("Failed to enforce keys on %s (%s:%d) user '%s': %v", server.Name, server.Hostname, server.Port, systemUser, err), + "worker") + errors = 1 + return + } + + removed = unauthorized + s.audit.Log(0, audit.ActionEnforcementApplied, + fmt.Sprintf("Enforced authorized_keys on %s (%s:%d) user '%s': removed %d unauthorized key(s)", + server.Name, server.Hostname, server.Port, systemUser, removed), + "worker") + + return +} + +// normalizeKey normalizes a public key line for comparison (strips comments and whitespace variations) +func normalizeKey(key string) string { + key = strings.TrimSpace(key) + if key == "" || strings.HasPrefix(key, "#") { + return "" + } + // SSH public keys have format: type base64data [comment] + // We compare type + base64data only (ignore the comment) + parts := strings.Fields(key) + if len(parts) >= 2 { + return parts[0] + " " + parts[1] + } + return key +} + +// setSetting writes a value to the settings table (upsert) +func (s *Service) setSetting(key, value string) { + s.db.Exec( + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, + key, value, + ) +} + +// GetStatus returns the current enforcement worker status for display +func (s *Service) GetStatus() map[string]string { + status := make(map[string]string) + + var val string + if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_mode'`).Scan(&val); err == nil { + status["mode"] = val + } else { + status["mode"] = ModeDisabled + } + + if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_interval'`).Scan(&val); err == nil { + status["interval"] = val + } else { + status["interval"] = fmt.Sprintf("%d", DefaultInterval) + } + + if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_last_run'`).Scan(&val); err == nil { + status["last_run"] = val + } + + if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_last_result'`).Scan(&val); err == nil { + status["last_result"] = val + } + + return status +} diff --git a/web/templates/admin_settings.html b/web/templates/admin_settings.html index dc89538..2641b02 100644 --- a/web/templates/admin_settings.html +++ b/web/templates/admin_settings.html @@ -284,6 +284,87 @@ + +

+
+
+

Key Enforcement

+
+
+
+
+
+
+

Enforced Key Management

+
+ When enabled, Keywarden periodically connects to all managed servers and verifies that only + authorized SSH keys (managed by Keywarden + the system master key) are present in + authorized_keys. Unauthorized keys are detected and optionally removed automatically. +

+ Monitor mode: Detects unauthorized keys and logs them in the audit log, but does not remove them.
+ Enforce mode: Detects unauthorized keys and removes them automatically, keeping only Keywarden-managed keys. +
+
+
+
+ +
+ +
+
+ + + Choose how Keywarden handles unauthorized keys on your servers. +
+
+ + + How often Keywarden checks the servers (1–1440 minutes). +
+
+ + +
+ + {{if and .EnforcementStatus (index .EnforcementStatus "last_run")}} +
+

Last Enforcement Run

+
+
+
Last Run
+
{{index .EnforcementStatus "last_run"}}
+
+
+
Result
+
{{index .EnforcementStatus "last_result"}}
+
+
+ {{end}} + + {{if and .EnforcementStatus (ne (index .EnforcementStatus "mode") "disabled")}} +
+

Manual Run

+
+ + Trigger an immediate enforcement check on all servers. +
+ {{end}} +
+
+
+
From c4171e5b871cff3784c26893b1e8f03c6a5b7830 Mon Sep 17 00:00:00 2001 From: scriptos Date: Tue, 7 Apr 2026 20:47:22 +0200 Subject: [PATCH 3/8] feat: protect initial owner from role change and deletion --- docs/roles.md | 3 +++ internal/auth/auth.go | 44 ++++++++++++++++++++++++++++++++++- internal/database/database.go | 20 ++++++++++++++++ internal/handlers/handlers.go | 40 +++++++++++++++++++++++++++---- web/templates/users.html | 2 ++ web/templates/users_edit.html | 8 +++++++ 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/docs/roles.md b/docs/roles.md index df58b3c..a2a2eaa 100644 --- a/docs/roles.md +++ b/docs/roles.md @@ -98,11 +98,14 @@ The **Owner** role has unrestricted access. In addition to all Admin permissions #### Owner Protections +- **Initial owner is permanently protected**: The owner account created during installation cannot be deleted, and its role cannot be changed. This is enforced both server-side and in the UI. - The last owner account cannot be deleted - The owner can always access Admin Settings, even when MFA enforcement would otherwise redirect them (to prevent lockout) - On first startup, the initial account is always created with the `owner` role - If no owner exists (e.g., after a migration from an older version), the first admin is automatically promoted to owner +> **Note:** Existing installations are automatically migrated — the oldest owner (by ID) is marked as the initial owner during the database migration. + ## Audit Log Visibility The audit log has role-based filtering: diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 940543a..8f5ce8e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -240,7 +240,7 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) { return false, "", fmt.Errorf("failed to hash password: %w", err) } - _, err = s.db.Exec( + result, err := s.db.Exec( `INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`, username, email, string(hash), "owner", ) @@ -248,6 +248,11 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) { return false, "", err } + // Store the ID of the initial owner so it can never be deleted or downgraded. + if ownerID, idErr := result.LastInsertId(); idErr == nil { + s.markInitialOwner(ownerID) + } + // Mark initial setup as complete so the password is never regenerated. s.markInitialSetupComplete() @@ -262,6 +267,43 @@ func (s *Service) isInitialSetupComplete() bool { return err == nil && val == "true" } +// markInitialOwner stores the user ID of the initial owner in the settings table. +func (s *Service) markInitialOwner(userID int64) { + s.db.Exec( + `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', ?, CURRENT_TIMESTAMP)`, + fmt.Sprintf("%d", userID), + ) +} + +// IsInitialOwner returns true if the given user ID is the initial owner +// created during installation. This owner cannot be deleted or downgraded. +func (s *Service) IsInitialOwner(userID int64) bool { + var val string + err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&val) + if err != nil { + return false + } + stored, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return false + } + return stored == userID +} + +// GetInitialOwnerID returns the user ID of the initial owner, or 0 if not set. +func (s *Service) GetInitialOwnerID() int64 { + var val string + err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&val) + if err != nil { + return 0 + } + id, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return 0 + } + return id +} + // markInitialSetupComplete persists the initial-setup flag in the settings table. func (s *Service) markInitialSetupComplete() { s.db.Exec( diff --git a/internal/database/database.go b/internal/database/database.go index dcd17c8..159613f 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -246,5 +246,25 @@ func (d *DB) migrate() error { } } + // Migration: backfill initial_owner_id for existing installations + { + var migCount int + d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'backfill_initial_owner_id'`).Scan(&migCount) + if migCount == 0 { + // Only set if not already present (new installs set it in EnsureAdmin) + var existing string + err := d.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&existing) + if err != nil || existing == "" { + // Pick the oldest owner (lowest ID) as the initial owner + var ownerID int64 + err := d.QueryRow(`SELECT id FROM users WHERE role = 'owner' ORDER BY id ASC LIMIT 1`).Scan(&ownerID) + if err == nil && ownerID > 0 { + d.Exec(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', CAST(? AS TEXT), CURRENT_TIMESTAMP)`, ownerID) + } + } + d.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_initial_owner_id')`) + } + } + return nil } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f655cb6..99c965c 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -174,6 +174,10 @@ type PageData struct { // Key Enforcement EnforcementStatus map[string]string + + // Initial Owner protection + IsInitialOwner bool + InitialOwnerID int64 } // SystemInfo holds runtime system information for the settings page @@ -653,6 +657,11 @@ func isOwner(role string) bool { return role == "owner" } +// getInitialOwnerID returns the user ID of the initial owner (0 if not set) +func (h *Handler) getInitialOwnerID() int64 { + return h.auth.GetInitialOwnerID() +} + func (h *Handler) getUserID(r *http.Request) int64 { id, _ := strconv.ParseInt(r.Header.Get("X-User-ID"), 10, 64) return id @@ -1826,10 +1835,11 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) { } data := &PageData{ - Title: "User Management", - Active: "users", - User: user, - Users: users, + Title: "User Management", + Active: "users", + User: user, + Users: users, + InitialOwnerID: h.getInitialOwnerID(), } h.templates["users"].ExecuteTemplate(w, "base", data) } @@ -2002,6 +2012,7 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) { User: user, EditUser: targetUser, PasswordPolicy: &policy, + IsInitialOwner: h.auth.IsInitialOwner(targetID), } h.templates["users_edit"].ExecuteTemplate(w, "base", data) return @@ -2014,6 +2025,22 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) { newPassword := r.FormValue("password") forceChange := r.FormValue("must_change_password") == "1" + // Initial Owner protection: role must remain "owner" + if h.auth.IsInitialOwner(targetID) && role != "owner" { + policy := h.auth.GetPasswordPolicy() + data := &PageData{ + Title: "Edit User", + Active: "users", + User: user, + EditUser: targetUser, + PasswordPolicy: &policy, + IsInitialOwner: true, + Flash: &Flash{Type: "danger", Message: "The initial owner role cannot be changed. This account was created during installation and is permanently protected."}, + } + h.templates["users_edit"].ExecuteTemplate(w, "base", data) + return + } + // Enforce role restrictions: // - Admin can only assign "user" role // - Only owner can assign "admin" or "owner" @@ -2107,6 +2134,11 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) { case "delete": if r.Method == http.MethodPost { + // Initial Owner protection: cannot be deleted + if h.auth.IsInitialOwner(targetID) { + http.Redirect(w, r, "/users", http.StatusSeeOther) + return + } // Owner protection: cannot self-delete if targetID == userID { http.Redirect(w, r, "/users", http.StatusSeeOther) diff --git a/web/templates/users.html b/web/templates/users.html index 4055591..6ffdf99 100644 --- a/web/templates/users.html +++ b/web/templates/users.html @@ -78,11 +78,13 @@ + {{if ne .ID $.InitialOwnerID}}
+ {{end}}
diff --git a/web/templates/users_edit.html b/web/templates/users_edit.html index b5956ca..a6548cb 100644 --- a/web/templates/users_edit.html +++ b/web/templates/users_edit.html @@ -38,6 +38,13 @@
+ {{if .IsInitialOwner}} + + + The initial owner role cannot be changed. This account was created during installation and is permanently protected. + {{else}} + {{end}}
From a63f3fb5ffe2247408acaaa97eca78fd59eb5686 Mon Sep 17 00:00:00 2001 From: scriptos Date: Tue, 7 Apr 2026 22:14:56 +0200 Subject: [PATCH 4/8] feat: add 5 theme pairs (ocean, forest, sunset, rose, nord) with light/dark/auto modes\n\n- Override Tabler dark-mode surface/border CSS variables per theme to remove blue tint\n- Add theme accent colors for badges, buttons, links, forms\n- Make Ocean the default theme, auto-migrate legacy values (auto/light/dark)\n- Update settings dropdown with grouped theme options\n- Update user-guide docs with new theme descriptions" --- docs/user-guide.md | 16 +- internal/auth/auth.go | 22 ++- web/templates/layout/base.html | 303 ++++++++++++++++++++++++++++++--- web/templates/settings.html | 30 +++- 4 files changed, 342 insertions(+), 29 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 361e07e..7932234 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -88,11 +88,23 @@ Navigate to **Settings** to manage your account: ### Theme -Choose between: -- **Auto** — Follows your system/browser preference +KeyWarden offers five color themes, each available in three modes: + +| Theme | Description | +|-------|-------------| +| **Ocean** (default) | Cyan/teal accent | +| **Forest** | Green accent | +| **Sunset** | Amber/orange accent | +| **Rose** | Pink accent | +| **Nord** | Cool blue-gray accent | + +Each theme supports: +- **System** — Follows your system/browser preference (light or dark) - **Light** — Always light mode - **Dark** — Always dark mode +> Existing installations using the previous theme values (`auto`, `light`, `dark`) are automatically migrated to the Ocean theme. + ### Password Change Change your password. The new password must comply with the configured password policy (displayed on the form). diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8f5ce8e..8221f32 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -401,10 +401,26 @@ func (s *Service) DisableMFA(userID int64) error { return err } -// UpdateTheme updates the user's theme preference (auto, light, dark) +// UpdateTheme updates the user's theme preference func (s *Service) UpdateTheme(id int64, theme string) error { - if theme != "auto" && theme != "light" && theme != "dark" { - theme = "auto" + // Map legacy default values to ocean + switch theme { + case "auto", "": + theme = "ocean-auto" + case "light": + theme = "ocean-light" + case "dark": + theme = "ocean-dark" + } + validThemes := map[string]bool{ + "ocean-auto": true, "ocean-light": true, "ocean-dark": true, + "forest-auto": true, "forest-light": true, "forest-dark": true, + "sunset-auto": true, "sunset-light": true, "sunset-dark": true, + "rose-auto": true, "rose-light": true, "rose-dark": true, + "nord-auto": true, "nord-light": true, "nord-dark": true, + } + if !validThemes[theme] { + theme = "ocean-auto" } _, err := s.db.Exec( `UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, diff --git a/web/templates/layout/base.html b/web/templates/layout/base.html index b0fd1c7..02c5ee2 100644 --- a/web/templates/layout/base.html +++ b/web/templates/layout/base.html @@ -12,21 +12,31 @@ @@ -558,14 +796,36 @@ {{end}} From 653592e68f13a2636dcb93f4630afa801eebeea9 Mon Sep 17 00:00:00 2001 From: scriptos Date: Tue, 7 Apr 2026 23:13:26 +0200 Subject: [PATCH 7/8] feat: add automatic update checker with version injection - Add internal/updater package (queries Gitea releases API every 6h) - Inject version at build time via -ldflags (-X main.Version) - Show update badge in header for admin/owner users - Show version on system info page - Add VERSION build arg to Dockerfile - Update docs (deployment, architecture, admin-guide, contributing, README) --- Dockerfile | 4 +- README.md | 1 + cmd/keywarden/main.go | 21 +++- docs/admin-guide.md | 7 ++ docs/architecture.md | 1 + docs/contributing.md | 6 +- docs/deployment.md | 5 + internal/handlers/handlers.go | 17 ++- internal/updater/updater.go | 193 +++++++++++++++++++++++++++++++++ web/templates/layout/base.html | 9 ++ web/templates/system_info.html | 11 ++ 11 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 internal/updater/updater.go diff --git a/Dockerfile b/Dockerfile index e1f42a4..647e3c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,9 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/ + +ARG VERSION=dev +RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X main.Version=${VERSION}" ./cmd/keywarden/ # Stage 2: Runtime FROM alpine:3.21 diff --git a/README.md b/README.md index 677144c..3d83236 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users - **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection - **Audit Log** — Every action tracked with user, IP, timestamp, and details +- **Update Notifications** — Automatic update check with version badge in the header for admins - **Encrypted Backup/Restore** — Full database export with password-based encryption - **Docker-Native** — Single container with embedded SQLite, no external database required diff --git a/cmd/keywarden/main.go b/cmd/keywarden/main.go index 2a8c327..cc3c673 100644 --- a/cmd/keywarden/main.go +++ b/cmd/keywarden/main.go @@ -24,10 +24,18 @@ import ( "git.techniverse.net/scriptos/keywarden/internal/mail" "git.techniverse.net/scriptos/keywarden/internal/security" "git.techniverse.net/scriptos/keywarden/internal/servers" + "git.techniverse.net/scriptos/keywarden/internal/updater" "git.techniverse.net/scriptos/keywarden/internal/worker" "git.techniverse.net/scriptos/keywarden/web" ) +// Version is set at build time via -ldflags: +// +// go build -ldflags "-X main.Version=v1.0.0" ./cmd/keywarden/ +// +// When building with Docker, pass --build-arg VERSION=v1.0.0 +var Version = "dev" + func main() { // Handle CLI subcommands before starting the server if len(os.Args) > 1 { @@ -47,7 +55,7 @@ func main() { // Initialize structured logging logging.Init(cfg.LogLevel) - logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment") + logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", Version) logging.Info(" https://git.techniverse.net/scriptos/keywarden") // Validate data paths – relative paths inside a container bypass the @@ -117,8 +125,11 @@ func main() { logging.Info("Base URL: %s", cfg.BaseURL) } + // Initialize update checker + updaterSvc := updater.NewService(Version) + // Setup HTTP handlers - handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL) + handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL, updaterSvc) mux := http.NewServeMux() handler.RegisterRoutes(mux) @@ -148,6 +159,10 @@ func main() { workerSvc.Start() defer workerSvc.Stop() + // Start update checker + updaterSvc.Start() + defer updaterSvc.Stop() + // Start server addr := ":" + cfg.Port logging.Info("Server starting on http://0.0.0.0%s", addr) @@ -278,7 +293,7 @@ func handleResetPassword(args []string) { // printUsage displays available CLI subcommands func printUsage() { - fmt.Println("Keywarden - Centralized SSH Key Management and Deployment") + fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", Version) fmt.Println() fmt.Println("Usage:") fmt.Println(" keywarden Start the server") diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 5d32637..b495722 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -209,12 +209,19 @@ Deleting a user removes their SSH keys, server records, and all related data (CA Navigate to **System** to view runtime information: +- Application version (with update badge if a newer release is available) - Go version, OS, architecture - CPU count, goroutine count - Memory allocation - Runtime environment (Docker or native) - Hostname and uptime +## Update Notifications + +Keywarden automatically checks for new releases in the background by querying the Gitea releases API. If a newer version is available, a yellow update badge is displayed in the top header for **Admin** and **Owner** users. The badge links directly to the release page on Gitea. + +The update checker is only active when the application was built with a version tag (via `--build-arg VERSION=...`). Development builds (`dev`) skip the check entirely. + ## Admin Settings (Owner Only) See [Roles & Permissions](roles.md) for details on which settings are owner-only. diff --git a/docs/architecture.md b/docs/architecture.md index fb1a78f..2c84438 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ internal/ security/ ← CSRF, security headers, rate limiting, proxy detection servers/ ← Server and server group management, access assignments sshutil/ ← SSH key generation (RSA, Ed25519, Ed448) + updater/ ← Background update checker (Gitea releases API) worker/ ← Background key enforcement worker (Bastillion-style) web/ embed.go ← Go embed directives for templates and static files diff --git a/docs/contributing.md b/docs/contributing.md index 12769b6..58609e7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -42,6 +42,9 @@ go mod download # Build CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/ +# Build with version (optional, enables update checker) +CGO_ENABLED=1 go build -ldflags="-X 'main.Version=v1.0.0'" -o keywarden ./cmd/keywarden/ + # Run ./keywarden ``` @@ -82,7 +85,8 @@ keywarden/ │ │ ├── ratelimit.go # IP-based rate limiting middleware │ │ └── sizelimit.go # Request body size limit middleware │ ├── servers/servers.go # Server and group management, access assignments -│ └── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448) +│ ├── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448) +│ └── updater/updater.go # Background update checker (Gitea releases API) ├── web/ │ ├── embed.go # Go embed directives │ ├── static/ # CSS, JS, fonts (Tabler UI) diff --git a/docs/deployment.md b/docs/deployment.md index bfa4b33..2e4c077 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -32,6 +32,9 @@ docker compose build # Or build manually docker build -t keywarden . + +# Build with a specific version tag (recommended for releases) +docker build --build-arg VERSION=v1.0.0 -t keywarden:v1.0.0 . ``` ### Multi-Stage Build @@ -43,6 +46,8 @@ The Dockerfile uses a two-stage build: The runtime container runs as a non-root user (`keywarden`). +The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. This enables the built-in update checker to compare the running version against the latest Gitea release. If omitted, the version defaults to `dev` and the update checker is disabled. + ### Docker Compose A complete `docker-compose.yml`: diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 99c965c..3bec443 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -39,6 +39,7 @@ import ( "git.techniverse.net/scriptos/keywarden/internal/models" "git.techniverse.net/scriptos/keywarden/internal/security" "git.techniverse.net/scriptos/keywarden/internal/servers" + "git.techniverse.net/scriptos/keywarden/internal/updater" "git.techniverse.net/scriptos/keywarden/internal/worker" ) @@ -59,6 +60,7 @@ type Handler struct { cron *cron.Service worker *worker.Service mail *mail.Service + updater *updater.Service db *database.DB // direct database access for backup/restore templates map[string]*template.Template sessions map[string]*sessionData // cookie -> session data with timeout tracking @@ -251,7 +253,7 @@ func formatUptime(start time.Time) string { } // New creates a new Handler -func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string) *Handler { +func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string, updaterSvc *updater.Service) *Handler { // Create sub-FS so /static/css/... maps to static/css/... in embed staticSub, err := fs.Sub(staticFS, "static") if err != nil { @@ -273,6 +275,7 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi cron: cronSvc, worker: workerSvc, mail: mailSvc, + updater: updaterSvc, db: db, sessions: make(map[string]*sessionData), pending: make(map[string]int64), @@ -302,6 +305,18 @@ func (h *Handler) loadTemplates(templateFS embed.FS) { } return name }, + "appVersion": func() string { + return h.updater.CurrentVersion() + }, + "updateAvailable": func() bool { + return h.updater.HasUpdate() + }, + "latestVersion": func() string { + return h.updater.LatestVersion() + }, + "releaseURL": func() string { + return h.updater.ReleaseURL() + }, } baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html") diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..2e4003e --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,193 @@ +// Keywarden - Centralized SSH Key Management and Deployment +// Copyright (C) 2026 Patrick Asmus (scriptos) +// SPDX-License-Identifier: AGPL-3.0-or-later + +package updater + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "git.techniverse.net/scriptos/keywarden/internal/logging" +) + +const ( + // Gitea API endpoint for releases + releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5" + // How often to check for updates + checkInterval = 6 * time.Hour + // HTTP timeout for API requests + httpTimeout = 15 * time.Second +) + +// giteaRelease represents the relevant fields from the Gitea releases API +type giteaRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +// Service checks for new releases in the background +type Service struct { + currentVersion string + + mu sync.RWMutex + latestVersion string + releaseURL string + hasUpdate bool + + stopCh chan struct{} +} + +// NewService creates an update checker. Pass the current application version +// (e.g. "v1.0.0" or "dev"). The checker runs in the background and queries +// the Gitea releases API periodically. +func NewService(currentVersion string) *Service { + return &Service{ + currentVersion: currentVersion, + stopCh: make(chan struct{}), + } +} + +// Start begins periodic update checks in the background. +func (s *Service) Start() { + // Don't check if running a dev build + if s.currentVersion == "" || s.currentVersion == "dev" { + logging.Info("Update checker disabled (development build)") + return + } + logging.Info("Update checker started (current version: %s, checking every %s)", s.currentVersion, checkInterval) + + go s.run() +} + +// Stop signals the background goroutine to exit. +func (s *Service) Stop() { + close(s.stopCh) +} + +// HasUpdate returns true if a newer version is available. +func (s *Service) HasUpdate() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.hasUpdate +} + +// LatestVersion returns the tag name of the latest release (e.g. "v1.2.0"). +func (s *Service) LatestVersion() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.latestVersion +} + +// ReleaseURL returns the HTML link to the latest release page on Gitea. +func (s *Service) ReleaseURL() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.releaseURL +} + +// CurrentVersion returns the running application version. +func (s *Service) CurrentVersion() string { + return s.currentVersion +} + +func (s *Service) run() { + // Initial check shortly after startup + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + + for { + select { + case <-s.stopCh: + return + case <-timer.C: + s.check() + timer.Reset(checkInterval) + } + } +} + +func (s *Service) check() { + client := &http.Client{Timeout: httpTimeout} + + resp, err := client.Get(releasesAPI) + if err != nil { + logging.Warn("Update check failed: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logging.Warn("Update check: Gitea API returned status %d", resp.StatusCode) + return + } + + var releases []giteaRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + logging.Warn("Update check: failed to parse response: %v", err) + return + } + + // Find the latest stable release (not draft, not prerelease) + for _, rel := range releases { + if rel.Draft || rel.Prerelease || rel.TagName == "" { + continue + } + + s.mu.Lock() + s.latestVersion = rel.TagName + s.releaseURL = rel.HTMLURL + s.hasUpdate = isNewer(rel.TagName, s.currentVersion) + s.mu.Unlock() + + if s.HasUpdate() { + logging.Info("New version available: %s (current: %s)", rel.TagName, s.currentVersion) + } + return + } +} + +// isNewer returns true if latest is a higher version than current. +// Both may optionally have a "v" prefix (e.g. "v1.2.3"). +func isNewer(latest, current string) bool { + latestParts := parseVersion(latest) + currentParts := parseVersion(current) + + for i := 0; i < len(latestParts) || i < len(currentParts); i++ { + l, c := 0, 0 + if i < len(latestParts) { + l = latestParts[i] + } + if i < len(currentParts) { + c = currentParts[i] + } + if l > c { + return true + } + if l < c { + return false + } + } + return false +} + +// parseVersion strips the "v" prefix and splits "1.2.3" into [1, 2, 3]. +func parseVersion(v string) []int { + v = strings.TrimPrefix(v, "v") + parts := strings.Split(v, ".") + nums := make([]int, 0, len(parts)) + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + break + } + nums = append(nums, n) + } + return nums +} diff --git a/web/templates/layout/base.html b/web/templates/layout/base.html index 02c5ee2..9174d86 100644 --- a/web/templates/layout/base.html +++ b/web/templates/layout/base.html @@ -582,6 +582,15 @@
+ + {{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}} + + {{end}}{{end}}