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. ---- + +![Keywarden Dashboard](assets/img/dashboard.png) + ## ⚠️ 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 @@ - @@ -236,6 +236,26 @@ {{.MasterKeyFingerprint}} {{end}} diff --git a/web/templates/layout/base.html b/web/templates/layout/base.html index b0fd1c7..9174d86 100644 --- a/web/templates/layout/base.html +++ b/web/templates/layout/base.html @@ -12,21 +12,31 @@ @@ -344,6 +582,15 @@
+ + {{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}} + + {{end}}{{end}}