diff --git a/.gitea/workflows/pr-test.yml b/.gitea/workflows/pr-test.yml
index 037dc13..e0a2fe0 100644
--- a/.gitea/workflows/pr-test.yml
+++ b/.gitea/workflows/pr-test.yml
@@ -11,7 +11,7 @@ jobs:
name: Lint, Build & Test
runs-on: ubuntu-latest
container:
- image: golang:1.26-alpine
+ image: golang:1.26.2-alpine
steps:
- name: Install build dependencies
diff --git a/.gitea/workflows/security-scan.yml b/.gitea/workflows/security-scan.yml
index 1915644..72849b8 100644
--- a/.gitea/workflows/security-scan.yml
+++ b/.gitea/workflows/security-scan.yml
@@ -11,7 +11,7 @@ jobs:
name: Go Vulnerability Check
runs-on: ubuntu-latest
container:
- image: golang:1.26-alpine
+ image: golang:1.26.2-alpine
steps:
- name: Install dependencies
diff --git a/Dockerfile b/Dockerfile
index e1f42a4..a51d059 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@
# Multi-stage build for minimal image size
# Stage 1: Build
-FROM golang:1.26-alpine AS builder
+FROM golang:1.26.2-alpine AS builder
RUN apk add --no-cache gcc musl-dev sqlite-dev
@@ -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 19550fc..3d83236 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.
----
+
+
+
## ⚠️ Alpha Software — Important Notice
@@ -23,9 +25,11 @@
- **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
+- **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/assets/img/dashboard.png b/assets/img/dashboard.png
new file mode 100644
index 0000000..c8cb417
Binary files /dev/null and b/assets/img/dashboard.png differ
diff --git a/cmd/keywarden/main.go b/cmd/keywarden/main.go
index e97ac03..cc3c673 100644
--- a/cmd/keywarden/main.go
+++ b/cmd/keywarden/main.go
@@ -24,9 +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 {
@@ -46,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
@@ -76,6 +85,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)
@@ -115,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, 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)
@@ -142,6 +155,14 @@ func main() {
cronSvc.Start()
defer cronSvc.Stop()
+ // Start key enforcement worker
+ 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)
@@ -272,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/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..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.
@@ -234,6 +241,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..2c84438 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,6 +39,8 @@ 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
static/ ← CSS, JS, fonts (Tabler UI framework)
@@ -59,7 +61,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/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/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/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/docs/user-guide.md b/docs/user-guide.md
index 361e07e..da5528c 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -57,11 +57,11 @@ The **Keys** page lists all your SSH keys with:
Admins and owners see all keys in the system, grouped by owner.
-### Downloading Keys
+### Viewing and Downloading Keys
-From the key list, you can download:
-- **Public Key** — For deployment to servers
-- **Private Key** — Decrypted and downloaded (use with caution)
+From the key list, you can:
+- **View Public Key** — Opens a modal overlay showing the public key with a copy-to-clipboard button
+- **Download Private Key** — Decrypted and downloaded (use with caution)
### Deleting Keys
@@ -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/go.mod b/go.mod
index bb8c8b6..3173bb4 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module git.techniverse.net/scriptos/keywarden
-go 1.26.1
+go 1.26.2
require (
github.com/cloudflare/circl v1.6.3
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/auth/auth.go b/internal/auth/auth.go
index 940543a..8221f32 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(
@@ -359,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/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/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..3bec443 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -39,6 +39,8 @@ 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"
)
// sessionData holds session metadata for timeout tracking
@@ -56,7 +58,9 @@ type Handler struct {
deploy *deploy.Service
audit *audit.Service
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
@@ -169,6 +173,13 @@ type PageData struct {
// System Information
SystemInfo *SystemInfo
+
+ // Key Enforcement
+ EnforcementStatus map[string]string
+
+ // Initial Owner protection
+ IsInitialOwner bool
+ InitialOwnerID int64
}
// SystemInfo holds runtime system information for the settings page
@@ -242,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, 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 {
@@ -262,7 +273,9 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
deploy: deploySvc,
audit: auditSvc,
cron: cronSvc,
+ worker: workerSvc,
mail: mailSvc,
+ updater: updaterSvc,
db: db,
sessions: make(map[string]*sessionData),
pending: make(map[string]int64),
@@ -292,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")
@@ -432,6 +457,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).
@@ -646,6 +672,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
@@ -1819,10 +1850,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)
}
@@ -1995,6 +2027,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
@@ -2007,6 +2040,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"
@@ -2100,6 +2149,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)
@@ -2993,6 +3047,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 +3102,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 +3204,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/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/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..4756700 100644
--- a/web/templates/admin_settings.html
+++ b/web/templates/admin_settings.html
@@ -226,7 +226,7 @@
-