18 Commits

Author SHA1 Message Date
eb1f4e0738 fix: bump Go from 1.26.1 to 1.26.2 to resolve stdlib vulnerabilities
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 6m7s
Security Scan / Go Vulnerability Check (pull_request) Successful in 5m33s
2026-04-08 20:09:40 +02:00
653592e68f feat: add automatic update checker with version injection
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Successful in 5m45s
Security Scan / Go Vulnerability Check (pull_request) Failing after 5m42s
- Add internal/updater package (queries Gitea releases API every 6h)

- Inject version at build time via -ldflags (-X main.Version)

- Show update badge in header for admin/owner users

- Show version on system info page

- Add VERSION build arg to Dockerfile

- Update docs (deployment, architecture, admin-guide, contributing, README)
2026-04-07 23:13:26 +02:00
465a44fae9 feat: show public key in modal with copy button instead of plain text page 2026-04-07 22:57:58 +02:00
05f8698c6b fix: add clipboard fallback for master key copy on HTTP 2026-04-07 22:43:47 +02:00
a63f3fb5ff feat: add 5 theme pairs (ocean, forest, sunset, rose, nord) with light/dark/auto modes\n\n- Override Tabler dark-mode surface/border CSS variables per theme to remove blue tint\n- Add theme accent colors for badges, buttons, links, forms\n- Make Ocean the default theme, auto-migrate legacy values (auto/light/dark)\n- Update settings dropdown with grouped theme options\n- Update user-guide docs with new theme descriptions" 2026-04-07 22:14:56 +02:00
c4171e5b87 feat: protect initial owner from role change and deletion 2026-04-07 20:47:22 +02:00
8b9de9e83d feat: add Bastillion-style SSH key enforcement worker 2026-04-06 00:17:03 +02:00
3a843354b6 docs: add dashboard screenshot to README 2026-04-05 22:46:09 +02:00
61cc63d3f9 Merge pull request 'v0.2.1-alpha' (#3) from v0.2.1-alpha into master
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 5m31s
Reviewed-on: #3
2026-04-05 20:30:39 +00:00
f893d26791 fix: enforce LF line endings for shell scripts (.gitattributes)
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 5m30s
Security Scan / Go Vulnerability Check (pull_request) Successful in 4m47s
2026-04-05 22:17:51 +02:00
68777a5516 feat: add CLI password reset command (docker exec reset-password) 2026-04-05 22:17:46 +02:00
0fcd99a191 Merge pull request 'v0.2.0-alpha' (#2) from v0.2.0-alpha into master
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 5m33s
Reviewed-on: #2
2026-04-05 17:56:47 +00:00
025d23e5a6 docs: add container registry URL to deployment docs and README
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 5m0s
Security Scan / Go Vulnerability Check (pull_request) Successful in 4m46s
2026-04-05 19:45:47 +02:00
be05dd5eac fix: add entrypoint.sh to fix /data permission denied on bind-mount
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Has been cancelled
Security Scan / Go Vulnerability Check (pull_request) Has been cancelled
2026-04-05 19:42:18 +02:00
bb3bf0330f security: fix data loss on container restart due to relative paths
Root cause: .env.example used relative paths (./data/...) which resolve
to /app/data/ inside the container instead of the persistent volume at
/data/. This caused the database to be recreated on every container
restart, resetting the admin password to a new initial value.

Fixes:
- .env.example: comment out path settings with clear warning about
  relative paths; Dockerfile already provides correct absolute defaults
- auth: add initial_setup_complete flag in settings table as
  defence-in-depth so EnsureAdmin never re-creates an admin after
  the initial setup, even if the users table is unexpectedly empty
- main: add validateDataPaths() startup check that warns when relative
  container paths are detected (potential data-loss misconfiguration)
- auth_test: extend TestEnsureAdmin to verify the flag prevents
  admin re-creation after user deletion
2026-04-05 19:21:15 +02:00
c2d4148de6 add build to docker-compose 2026-04-05 19:12:44 +02:00
ea3e7e71ca refactor: convert force_password_change to standalone layout (no sidebar) 2026-04-05 19:03:32 +02:00
5bd77de32d Merge pull request 'v0.1.1-alpha' (#1) from v0.1.1-alpha into master
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 5m11s
Reviewed-on: #1
2026-04-05 16:41:18 +00:00
36 changed files with 2397 additions and 132 deletions

View File

@@ -20,10 +20,15 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
KEYWARDEN_LOG_LEVEL=INFO
# --- Paths (optional, Docker defaults are usually fine) ---
KEYWARDEN_DB_PATH=./data/keywarden.db
KEYWARDEN_DATA_DIR=./data
KEYWARDEN_KEYS_DIR=./data/keys
KEYWARDEN_MASTER_DIR=./data/master
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
# The Dockerfile already sets correct defaults (/data/...). Only override
# if you know what you are doing. Do NOT use relative paths (./data/...)
# they resolve to /app/data/ inside the container and bypass the
# persistent volume mount at /data, causing DATA LOSS on restart.
# KEYWARDEN_DB_PATH=/data/keywarden.db
# KEYWARDEN_DATA_DIR=/data
# KEYWARDEN_KEYS_DIR=/data/keys
# KEYWARDEN_MASTER_DIR=/data/master
# --- Security / Hardening (optional) ---
# Public URL used for email links and cookie config.

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ensure shell scripts always have Unix line endings (LF),
# even when checked out on Windows.
*.sh text eol=lf
entrypoint.sh text eol=lf

View File

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

View File

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

View File

@@ -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,22 +11,24 @@ 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
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl su-exec
RUN addgroup -S keywarden && adduser -S keywarden -G keywarden
WORKDIR /app
COPY --from=builder /build/keywarden .
COPY entrypoint.sh .
RUN mkdir -p /data/keys /data/master /data/avatars && \
chown -R keywarden:keywarden /data /app
USER keywarden
chown -R keywarden:keywarden /data /app && \
chmod +x /app/entrypoint.sh
ENV KEYWARDEN_PORT=8080
ENV KEYWARDEN_DB_PATH=/data/keywarden.db
@@ -42,4 +44,4 @@ VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${KEYWARDEN_PORT:-8080}/api/health || exit 1
ENTRYPOINT ["./keywarden"]
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -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
@@ -138,6 +142,7 @@ Join the **Keywarden Matrix chat** to discuss the project, ask questions, or sha
|---|---|
| **Primary (Gitea)** | [git.techniverse.net/scriptos/keywarden](https://git.techniverse.net/scriptos/keywarden) |
| **Mirror (GitHub)** | [github.com/pscriptos/keywarden](https://github.com/pscriptos/keywarden) |
| **Container Registry** | [git.techniverse.net/scriptos/-/packages/container/keywarden](https://git.techniverse.net/scriptos/-/packages/container/keywarden) |
The **primary repository** is hosted on Gitea. The GitHub repository is a read-only mirror.

BIN
assets/img/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -5,8 +5,11 @@
package main
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
@@ -21,19 +24,44 @@ 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 {
switch os.Args[1] {
case "reset-password":
handleResetPassword(os.Args[2:])
return
case "help", "--help", "-h":
printUsage()
return
}
}
// Load config first (needed for log level)
cfg := config.Load()
// 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
// persistent volume mount and lead to silent data loss on restart.
validateDataPaths(cfg)
// Ensure data directories exist
for _, dir := range []string{cfg.DataDir, cfg.KeysDir, cfg.MasterDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
@@ -57,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)
@@ -96,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)
@@ -123,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)
@@ -152,3 +192,116 @@ func getEnvWithLegacy(primary, legacy, fallback string) string {
}
return fallback
}
// validateDataPaths checks for a common misconfiguration: relative paths
// (e.g. ./data/...) that resolve to the container's working directory instead
// of the persistent volume mount. This would cause silent data loss on every
// container restart.
func validateDataPaths(cfg *config.Config) {
paths := map[string]string{
"KEYWARDEN_DB_PATH": cfg.DBPath,
"KEYWARDEN_DATA_DIR": cfg.DataDir,
"KEYWARDEN_KEYS_DIR": cfg.KeysDir,
"KEYWARDEN_MASTER_DIR": cfg.MasterDir,
}
for envVar, p := range paths {
if p == "" {
continue
}
abs, err := filepath.Abs(p)
if err != nil {
continue
}
// Detect relative paths that resolve outside /data (the expected volume).
if !filepath.IsAbs(p) || (!strings.HasPrefix(abs, "/data") && !strings.HasPrefix(abs, `\data`)) {
// Only warn don't block startup for non-Docker environments.
if strings.HasPrefix(p, "./") || strings.HasPrefix(p, "../") || (!filepath.IsAbs(p) && p != "") {
logging.Warn("⚠ %s is a relative path (%s → %s). Inside a Docker container this may bypass the persistent volume and cause DATA LOSS on restart. Use an absolute path like /data/... instead.", envVar, p, abs)
}
}
}
}
// handleResetPassword implements the "reset-password" CLI subcommand.
// Usage: keywarden reset-password --username <name> [--reset-mfa]
func handleResetPassword(args []string) {
var username string
var resetMFA bool
for i := 0; i < len(args); i++ {
switch args[i] {
case "--username", "-u":
if i+1 < len(args) {
i++
username = args[i]
} else {
fmt.Fprintln(os.Stderr, "Error: --username requires a value")
os.Exit(1)
}
case "--reset-mfa":
resetMFA = true
default:
fmt.Fprintf(os.Stderr, "Error: unknown flag '%s'\n", args[i])
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--reset-mfa]")
os.Exit(1)
}
}
if username == "" {
fmt.Fprintln(os.Stderr, "Error: --username is required")
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--reset-mfa]")
os.Exit(1)
}
// Load config for DB path
cfg := config.Load()
// Open database
db, err := database.New(cfg.DBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer db.Close()
authSvc := auth.NewService(db)
// Look up the user
user, err := authSvc.GetUserByUsername(username)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username)
os.Exit(1)
}
// Reset password
newPassword, err := authSvc.ResetPassword(user.ID, resetMFA)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to reset password: %v\n", err)
os.Exit(1)
}
fmt.Println("════════════════════════════════════════════════════════════")
fmt.Printf(" Password reset successful for user: %s\n", user.Username)
fmt.Printf(" New password: %s\n", newPassword)
if resetMFA {
fmt.Println(" MFA has been disabled for this account.")
}
fmt.Println(" The user must change this password after login.")
fmt.Println("════════════════════════════════════════════════════════════")
}
// printUsage displays available CLI subcommands
func printUsage() {
fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", Version)
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" keywarden Start the server")
fmt.Println(" keywarden reset-password --username <name> Reset a user's password")
fmt.Println(" --reset-mfa Also disable MFA")
fmt.Println(" keywarden help Show this help")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin")
fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa")
}

View File

@@ -1,6 +1,7 @@
services:
keywarden:
image: git.techniverse.net/scriptos/keywarden:latest
build: .
container_name: keywarden
restart: unless-stopped
ports:

View File

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

View File

@@ -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 (11440 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
@@ -246,3 +261,29 @@ Send a test email to verify SMTP configuration.
### Backup & Restore
See [Backup & Restore](backup-restore.md) for details.
## CLI Commands
Keywarden provides CLI commands for administrative tasks that can be run via `docker exec`.
### Password Reset
Reset a user's password when they are locked out or have forgotten it:
```bash
docker exec -it keywarden ./keywarden reset-password --username <name>
```
This generates a new random password, prints it to the terminal, and forces the user to change it on next login. The account lockout counter is also cleared.
To additionally disable MFA (e.g. when the user lost their TOTP device):
```bash
docker exec -it keywarden ./keywarden reset-password --username <name> --reset-mfa
```
### Help
```bash
docker exec -it keywarden ./keywarden help
```

View File

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

View File

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

View File

@@ -8,7 +8,23 @@ Keywarden is designed as a single-container application with an embedded SQLite
### Docker Image
Build from source or use the pre-built image:
Keywarden provides pre-built Docker images via the container registry:
**Container Registry:** [git.techniverse.net/scriptos/-/packages/container/keywarden](https://git.techniverse.net/scriptos/-/packages/container/keywarden)
Pull the latest image:
```bash
docker pull git.techniverse.net/scriptos/keywarden:latest
```
Or pull a specific version:
```bash
docker pull git.techniverse.net/scriptos/keywarden:v0.1.1-alpha
```
Alternatively, build from source:
```bash
# Build from source
@@ -16,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
@@ -27,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`:

View File

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

View File

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

View File

@@ -27,12 +27,13 @@ Common issues and solutions for Keywarden.
**Solutions**:
- Check the very first startup logs: `docker compose logs keywarden`
- If you missed the password, delete the database and restart to trigger a fresh setup:
- Reset the password via CLI command (no restart needed):
```bash
docker compose down
docker volume rm keywarden_keywarden_data
docker compose up -d
docker compose logs keywarden
docker exec -it keywarden ./keywarden reset-password --username admin
```
- If MFA is also lost, add `--reset-mfa`:
```bash
docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa
```
## Login Issues
@@ -50,7 +51,10 @@ Common issues and solutions for Keywarden.
**Solutions**:
- Wait for the lockout period to expire (default: 15 minutes)
- Ask an administrator to unlock the account from the user management page
- If you're the only owner: wait for the lockout to expire, or delete and recreate the database
- If you're the only owner: reset your password via CLI (this also clears lockout):
```bash
docker exec -it keywarden ./keywarden reset-password --username admin
```
### MFA Code Invalid

View File

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

15
entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Keywarden Docker Entrypoint
# Ensures data directories exist with correct ownership before
# dropping privileges to the keywarden user.
set -e
# Create data directories (bind-mount from host may be owned by root)
mkdir -p /data/keys /data/master /data/avatars
# Fix ownership so the unprivileged keywarden user can write
chown -R keywarden:keywarden /data
# Drop privileges and exec the application
exec su-exec keywarden ./keywarden "$@"

2
go.mod
View File

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

View File

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

View File

@@ -104,6 +104,59 @@ func (s *Service) Login(username, password string) (*models.User, error) {
return user, nil
}
// GetUserByUsername returns a user by their username
func (s *Service) GetUserByUsername(username string) (*models.User, error) {
user := &models.User{}
err := s.db.QueryRow(
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE username = ?`,
username,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to query user: %w", err)
}
return user, nil
}
// ResetPassword generates a new random password for the given user, sets
// must_change_password = true, resets lockout counters and optionally
// disables MFA. Returns the generated password.
func (s *Service) ResetPassword(userID int64, resetMFA bool) (string, error) {
password, err := generateSecurePassword(20)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
`UPDATE users SET password_hash = ?, must_change_password = 1, failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
string(hash), userID,
)
if err != nil {
return "", fmt.Errorf("failed to update password: %w", err)
}
if resetMFA {
_, err = s.db.Exec(
`UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
userID,
)
if err != nil {
return "", fmt.Errorf("failed to reset MFA: %w", err)
}
}
return password, nil
}
// GetUserByID returns a user by their ID
func (s *Service) GetUserByID(id int64) (*models.User, error) {
user := &models.User{}
@@ -156,12 +209,23 @@ func (s *Service) HasUsers() (bool, error) {
// EnsureAdmin creates a default owner user if no users exist.
// It auto-generates a secure password and returns (created, generatedPassword, error).
// A persistent flag ("initial_setup_complete") is stored in the settings table
// so that an admin account is never re-created after the initial setup, even
// if the users table is unexpectedly empty (e.g. due to a misconfigured volume).
func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
// Defence-in-depth: if the initial setup was already completed once,
// never auto-create another admin even when the users table is empty.
if s.isInitialSetupComplete() {
return false, "", nil
}
hasUsers, err := s.HasUsers()
if err != nil {
return false, "", err
}
if hasUsers {
// Users exist but no flag yet (upgrade path) set the flag now.
s.markInitialSetupComplete()
return false, "", nil
}
@@ -176,16 +240,77 @@ 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",
)
if err != nil {
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()
return true, password, nil
}
// isInitialSetupComplete checks whether the initial admin setup has already
// been performed by looking for a flag in the settings table.
func (s *Service) isInitialSetupComplete() bool {
var val string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_setup_complete'`).Scan(&val)
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(
`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_setup_complete', 'true', CURRENT_TIMESTAMP)`,
)
}
// generateSecurePassword creates a cryptographically secure random password
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -276,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 = ?`,

View File

@@ -236,7 +236,7 @@ func TestEnsureAdmin(t *testing.T) {
t.Fatalf("Expected owner role, got %q", user.Role)
}
// Second call should be no-op
// Second call should be no-op (users exist)
created2, _, err := svc.EnsureAdmin("admin2", "admin2@test.com")
if err != nil {
t.Fatalf("Second EnsureAdmin should not fail: %v", err)
@@ -250,6 +250,25 @@ func TestEnsureAdmin(t *testing.T) {
if err != ErrInvalidCredentials {
t.Fatalf("admin2 should not have been created")
}
// initial_setup_complete flag should be set
if !svc.isInitialSetupComplete() {
t.Fatal("Expected initial_setup_complete flag to be set after EnsureAdmin")
}
// Even if all users are deleted, EnsureAdmin must NOT create a new admin
// because the initial_setup_complete flag is set (defence-in-depth).
_, err = db.Exec(`DELETE FROM users`)
if err != nil {
t.Fatalf("Failed to delete all users: %v", err)
}
created3, _, err := svc.EnsureAdmin("admin3", "admin3@test.com")
if err != nil {
t.Fatalf("Third EnsureAdmin should not fail: %v", err)
}
if created3 {
t.Fatal("EnsureAdmin must not create a user when initial_setup_complete flag is set")
}
}
func TestGetAllUsers(t *testing.T) {
@@ -399,3 +418,100 @@ func TestEnableDisableMFA(t *testing.T) {
t.Fatal("MFA should be disabled")
}
}
func TestGetUserByUsername(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
user, err := svc.GetUserByUsername("testuser")
if err != nil {
t.Fatalf("GetUserByUsername failed: %v", err)
}
if user.ID != created.ID {
t.Fatalf("Expected user ID %d, got %d", created.ID, user.ID)
}
if user.Username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", user.Username)
}
// Non-existent user
_, err = svc.GetUserByUsername("nonexistent")
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound, got %v", err)
}
}
func TestResetPassword(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
// Reset without MFA reset
newPass, err := svc.ResetPassword(created.ID, false)
if err != nil {
t.Fatalf("ResetPassword failed: %v", err)
}
if len(newPass) != 20 {
t.Fatalf("Expected 20-char password, got %d chars", len(newPass))
}
// Old password should fail
_, err = svc.Login("testuser", "oldpass")
if err != ErrInvalidCredentials {
t.Fatal("Old password should no longer work after reset")
}
// New password should work
user, err := svc.Login("testuser", newPass)
if err != nil {
t.Fatalf("Login with reset password failed: %v", err)
}
if !user.MustChangePassword {
t.Fatal("must_change_password should be set after reset")
}
// Account lockout should be cleared
if user.FailedLoginAttempts != 0 {
t.Fatalf("Expected 0 failed attempts after reset, got %d", user.FailedLoginAttempts)
}
}
func TestResetPasswordWithMFA(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
// Enable MFA
svc.EnableMFA(created.ID, "TESTSECRET")
// Reset with MFA reset
newPass, err := svc.ResetPassword(created.ID, true)
if err != nil {
t.Fatalf("ResetPassword with MFA reset failed: %v", err)
}
// Verify MFA is disabled
user, err := svc.GetUserByID(created.ID)
if err != nil {
t.Fatalf("GetUserByID failed: %v", err)
}
if user.MFAEnabled {
t.Fatal("MFA should be disabled after reset with --reset-mfa")
}
if user.MFASecret != "" {
t.Fatalf("MFA secret should be empty after reset, got %q", user.MFASecret)
}
// New password should work
_, err = svc.Login("testuser", newPass)
if err != nil {
t.Fatalf("Login with reset password failed: %v", err)
}
}

View File

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

View File

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

View File

@@ -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")
@@ -306,7 +331,6 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
"admin_settings", "system_info",
"cron", "cron_add", "cron_edit",
"assignments", "assignments_add", "assignments_edit",
"force_password_change",
}
for _, page := range pages {
pageContent, err := fs.ReadFile(templateFS, "templates/"+page+".html")
@@ -335,6 +359,17 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
}
h.templates["login"] = loginTmpl
// Force password change page has its own layout (standalone, no sidebar)
fpcContent, err := fs.ReadFile(templateFS, "templates/force_password_change.html")
if err != nil {
logging.Fatal("Failed to read force_password_change template: %v", err)
}
fpcTmpl, err := template.New("force_password_change").Funcs(funcMap).Parse(string(fpcContent))
if err != nil {
logging.Fatal("Failed to parse force_password_change: %v", err)
}
h.templates["force_password_change"] = fpcTmpl
// MFA required page has its own layout (standalone, no sidebar)
mfaReqContent, err := fs.ReadFile(templateFS, "templates/mfa_required.html")
if err != nil {
@@ -422,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).
@@ -636,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
@@ -1809,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)
}
@@ -1985,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
@@ -1997,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"
@@ -2090,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)
@@ -2225,7 +2289,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
User: user,
PasswordPolicy: &policy,
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2239,7 +2303,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: "Passwords do not match."},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2250,7 +2314,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: err.Error()},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2261,7 +2325,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: "Failed to change password: " + err.Error()},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2983,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)
@@ -3037,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)
@@ -3114,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).

193
internal/updater/updater.go Normal file
View File

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

672
internal/worker/worker.go Normal file
View File

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

View File

@@ -226,7 +226,7 @@
<button class="btn btn-outline-secondary" type="button" onclick="toggleMasterKey()" title="Show/Hide">
<i class="ti ti-eye" id="masterKeyEyeIcon"></i>
</button>
<button class="btn btn-outline-primary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('masterKeyValue').value); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy">
<button class="btn btn-outline-primary" type="button" onclick="copyMasterKey(this)" title="Copy">
<i class="ti ti-copy"></i>
</button>
</div>
@@ -236,6 +236,26 @@
<code>{{.MasterKeyFingerprint}}</code>
</div>
<script>
function copyMasterKey(btn) {
var text = document.getElementById('masterKeyValue').value;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function() {
btn.innerHTML = '<i class="ti ti-check"></i>';
setTimeout(function() { btn.innerHTML = '<i class="ti ti-copy"></i>'; }, 2000);
});
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
btn.innerHTML = '<i class="ti ti-check"></i>';
setTimeout(function() { btn.innerHTML = '<i class="ti ti-copy"></i>'; }, 2000);
}
}
function toggleMasterKey() {
var input = document.getElementById('masterKeyDisplay');
var icon = document.getElementById('masterKeyEyeIcon');
@@ -284,6 +304,87 @@
</div>
</div>
<!-- Key Enforcement (Bastillion-Style) -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-shield-check"></i> Key Enforcement</h3>
</div>
<div class="card-body">
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Enforced Key Management</h4>
<div class="text-secondary">
When enabled, Keywarden periodically connects to all managed servers and verifies that only
authorized SSH keys (managed by Keywarden + the system master key) are present in
<code>authorized_keys</code>. Unauthorized keys are detected and optionally removed automatically.
<br><br>
<strong>Monitor mode:</strong> Detects unauthorized keys and logs them in the audit log, but does not remove them.<br>
<strong>Enforce mode:</strong> Detects unauthorized keys and <em>removes them automatically</em>, keeping only Keywarden-managed keys.
</div>
</div>
</div>
</div>
<form action="/admin/settings" method="post">
<input type="hidden" name="form_type" value="enforcement_settings">
<div class="row mb-3">
<div class="col-md-4 mb-3">
<label class="form-label">Enforcement Mode</label>
<select name="enforce_mode" class="form-select">
<option value="disabled" {{if or (not .EnforcementStatus) (eq (index .EnforcementStatus "mode") "disabled")}}selected{{end}}>Disabled</option>
<option value="monitor" {{if and .EnforcementStatus (eq (index .EnforcementStatus "mode") "monitor")}}selected{{end}}>Monitor (detect only)</option>
<option value="enforce" {{if and .EnforcementStatus (eq (index .EnforcementStatus "mode") "enforce")}}selected{{end}}>Enforce (detect &amp; remove)</option>
</select>
<small class="form-hint">Choose how Keywarden handles unauthorized keys on your servers.</small>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Check Interval (minutes)</label>
<input type="number" name="enforce_interval" class="form-control"
value="{{if and .EnforcementStatus (index .EnforcementStatus "interval")}}{{index .EnforcementStatus "interval"}}{{else}}15{{end}}"
min="1" max="1440" placeholder="15">
<small class="form-hint">How often Keywarden checks the servers (11440 minutes).</small>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Enforcement Settings
</button>
</div>
</form>
{{if and .EnforcementStatus (index .EnforcementStatus "last_run")}}
<hr class="my-4">
<h4 class="mb-3"><i class="ti ti-history"></i> Last Enforcement Run</h4>
<div class="datagrid mb-3">
<div class="datagrid-item">
<div class="datagrid-title">Last Run</div>
<div class="datagrid-content">{{index .EnforcementStatus "last_run"}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Result</div>
<div class="datagrid-content">{{index .EnforcementStatus "last_result"}}</div>
</div>
</div>
{{end}}
{{if and .EnforcementStatus (ne (index .EnforcementStatus "mode") "disabled")}}
<hr class="my-4">
<h4 class="mb-3"><i class="ti ti-player-play"></i> Manual Run</h4>
<form action="/admin/enforcement/run" method="post" onsubmit="return confirm('Start a key enforcement run now? This will connect to all managed servers.');">
<button type="submit" class="btn btn-warning">
<i class="ti ti-player-play"></i> Run Enforcement Now
</button>
<small class="form-hint d-inline-block ms-2">Trigger an immediate enforcement check on all servers.</small>
</form>
{{end}}
</div>
</div>
</div>
<!-- Backup & Restore -->
<div class="col-12">
<div class="card">

View File

@@ -1,65 +1,123 @@
{{define "content"}}
<div class="row row-deck row-cards justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-lock-exclamation"></i> Password Change Required</h3>
</div>
<div class="card-body">
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">You must change your password</h4>
<div class="text-secondary">Your administrator has set an initial password for your account. Please choose a new personal password to continue.</div>
</div>
</div>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Password Change Required - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<script>
(function() {
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
})();
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">
<i class="ti ti-lock-exclamation"></i> Password Change Required
</h2>
{{if .PasswordPolicy}}
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Password Requirements</h4>
<ul class="mb-0">
<li>Minimum <strong>{{.PasswordPolicy.MinLength}}</strong> characters</li>
{{if .PasswordPolicy.RequireUpper}}<li>At least one <strong>uppercase letter</strong> (A-Z)</li>{{end}}
{{if .PasswordPolicy.RequireLower}}<li>At least one <strong>lowercase letter</strong> (a-z)</li>{{end}}
{{if .PasswordPolicy.RequireDigit}}<li>At least one <strong>digit</strong> (0-9)</li>{{end}}
{{if .PasswordPolicy.RequireSpecial}}<li>At least one <strong>special character</strong> (!@#$...)</li>{{end}}
</ul>
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">You must change your password</h4>
<div class="text-secondary">Your administrator has set an initial password for your account. Please choose a new personal password to continue.</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
<form action="/password/change" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label required">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="new_password" class="form-control" placeholder="New password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
{{if .Flash}}
<div class="alert alert-{{.Flash.Type}}">
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
</div>
{{end}}
{{if .PasswordPolicy}}
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Password Requirements</h4>
<ul class="mb-0">
<li>Minimum <strong>{{.PasswordPolicy.MinLength}}</strong> characters</li>
{{if .PasswordPolicy.RequireUpper}}<li>At least one <strong>uppercase letter</strong> (A-Z)</li>{{end}}
{{if .PasswordPolicy.RequireLower}}<li>At least one <strong>lowercase letter</strong> (a-z)</li>{{end}}
{{if .PasswordPolicy.RequireDigit}}<li>At least one <strong>digit</strong> (0-9)</li>{{end}}
{{if .PasswordPolicy.RequireSpecial}}<li>At least one <strong>special character</strong> (!@#$...)</li>{{end}}
</ul>
</div>
</div>
</div>
{{end}}
<form action="/password/change" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label required">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="new_password" class="form-control" placeholder="New password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="mb-3">
<label class="form-label required">Confirm New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-lock-check"></i> Set New Password
</button>
</div>
</form>
<div class="text-center mt-3">
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
</div>
</div>
<div class="mb-3">
<label class="form-label required">Confirm New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-lock-check"></i> Set New Password
</button>
</div>
</form>
<div class="text-center mt-3">
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
</div>
</div>
</div>
</div>
</div>
{{end}}
<!-- CSRF Protection -->
<script>
(function() {
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
var token = m ? decodeURIComponent(m[1]) : '';
document.querySelectorAll('form').forEach(function(form) {
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = token;
form.prepend(input);
}
});
})();
</script>
</body>
</html>

View File

@@ -50,9 +50,9 @@
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>
<div class="btn-list flex-nowrap">
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
<i class="ti ti-eye"></i>
</a>
</button>
{{if eq .UserID $.User.ID}}
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
<i class="ti ti-download"></i>
@@ -92,9 +92,9 @@
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>
<div class="btn-list flex-nowrap">
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
<i class="ti ti-eye"></i>
</a>
</button>
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
<i class="ti ti-download"></i>
</a>
@@ -121,4 +121,104 @@
</div>
</div>
</div>
<!-- Public Key Modal -->
<div class="modal modal-blur fade" id="publicKeyModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="ti ti-key text-primary"></i> Public Key: <span id="publicKeyName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="publicKeyLoading" class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 text-secondary">Loading public key...</p>
</div>
<div id="publicKeyContent" class="d-none">
<textarea id="publicKeyText" class="form-control" rows="6" readonly style="font-family: monospace; font-size: 0.85rem;"></textarea>
</div>
<div id="publicKeyError" class="d-none">
<div class="alert alert-danger mb-0">
<i class="ti ti-alert-triangle"></i> Failed to load public key.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="copyPublicKeyBtn" onclick="copyPublicKey()">
<i class="ti ti-copy"></i> Copy to Clipboard
</button>
</div>
</div>
</div>
</div>
<script>
function showPublicKey(keyID, keyName) {
document.getElementById('publicKeyName').textContent = keyName;
document.getElementById('publicKeyLoading').classList.remove('d-none');
document.getElementById('publicKeyContent').classList.add('d-none');
document.getElementById('publicKeyError').classList.add('d-none');
document.getElementById('copyPublicKeyBtn').classList.remove('d-none');
var modal = new bootstrap.Modal(document.getElementById('publicKeyModal'));
modal.show();
fetch('/keys/' + keyID + '/view')
.then(function(response) {
if (!response.ok) throw new Error('Failed to load key');
return response.text();
})
.then(function(pubKey) {
document.getElementById('publicKeyText').value = pubKey;
document.getElementById('publicKeyLoading').classList.add('d-none');
document.getElementById('publicKeyContent').classList.remove('d-none');
})
.catch(function() {
document.getElementById('publicKeyLoading').classList.add('d-none');
document.getElementById('publicKeyError').classList.remove('d-none');
document.getElementById('copyPublicKeyBtn').classList.add('d-none');
});
}
function copyPublicKey() {
var textarea = document.getElementById('publicKeyText');
var text = textarea.value;
var success = false;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onCopySuccess).catch(fallbackCopy);
return;
}
fallbackCopy();
function fallbackCopy() {
textarea.select();
textarea.setSelectionRange(0, 99999);
try { success = document.execCommand('copy'); } catch(e) { success = false; }
if (success) { onCopySuccess(); } else { alert('Copy failed. Please select the key manually and copy it.'); }
}
function onCopySuccess() {
var btn = document.getElementById('copyPublicKeyBtn');
btn.innerHTML = '<i class="ti ti-check"></i> Copied!';
btn.classList.remove('btn-primary');
btn.classList.add('btn-success');
setTimeout(function() {
btn.innerHTML = '<i class="ti ti-copy"></i> Copy to Clipboard';
btn.classList.remove('btn-success');
btn.classList.add('btn-primary');
}, 2000);
}
}
// Move modal to body so it is not clipped by overflow containers
document.addEventListener('DOMContentLoaded', function() {
var modal = document.getElementById('publicKeyModal');
if (modal) {
document.body.appendChild(modal);
}
});
</script>
{{end}}

View File

@@ -12,21 +12,31 @@
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
<script>
(function() {
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'auto';
var resolved = theme;
if (theme === 'auto') {
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'ocean-auto';
// Map legacy default values to ocean
if (theme === 'auto' || theme === 'light' || theme === 'dark') {
theme = 'ocean-' + theme;
}
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
window.__kwThemeRaw = theme;
var pair = 'default', mode = theme;
if (theme !== 'auto' && theme !== 'light' && theme !== 'dark') {
var idx = theme.lastIndexOf('-');
if (idx > 0) { pair = theme.substring(0, idx); mode = theme.substring(idx + 1); }
}
if (mode !== 'light' && mode !== 'dark') {
mode = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', mode);
document.documentElement.style.colorScheme = mode;
if (pair !== 'default') document.documentElement.setAttribute('data-theme-pair', pair);
})();
</script>
<style>
/* Critical inline styles: prevent white flash between page navigations */
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #0F1829; color-scheme: dark; }
html[data-bs-theme="dark"] body { background-color: #0c1a2a; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
html[data-bs-theme="light"] body { background-color: #ecfeff; color-scheme: light; }
.navbar-brand-image { height: 2rem; }
.keywarden-brand { font-weight: 700; font-size: 1.25rem; color: #206bc4; }
[data-bs-theme="dark"] .keywarden-brand { color: #4da3ff; }
@@ -118,6 +128,8 @@
display: flex;
flex-direction: column;
}
[data-bs-theme="dark"] .navbar-vertical { background: #0a1120 !important; }
[data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #0a1120 !important; border-bottom-color: rgba(255,255,255,0.04); }
.navbar-vertical > .container-fluid {
flex: 1;
display: flex;
@@ -322,6 +334,232 @@
object-fit: cover;
border-radius: 50%;
}
/* ═══════════════════════════════════════════════════════════ */
/* ADDITIONAL THEME PAIRS */
/* ═══════════════════════════════════════════════════════════ */
/* Shared themed overrides (active when any theme pair is set) */
html[data-theme-pair] .btn-primary {
--tblr-btn-bg: var(--kw-primary);
--tblr-btn-border-color: var(--kw-primary);
--tblr-btn-hover-bg: var(--kw-primary-hover);
--tblr-btn-hover-border-color: var(--kw-primary-hover);
--tblr-btn-active-bg: var(--kw-primary-active);
--tblr-btn-active-border-color: var(--kw-primary-active);
}
html[data-theme-pair] .btn-outline-primary {
--tblr-btn-color: var(--kw-primary);
--tblr-btn-border-color: var(--kw-primary);
--tblr-btn-hover-bg: var(--kw-primary);
--tblr-btn-hover-border-color: var(--kw-primary);
--tblr-btn-active-bg: var(--kw-primary-hover);
--tblr-btn-active-border-color: var(--kw-primary-hover);
}
html[data-theme-pair] {
--tblr-link-color: var(--kw-primary);
--tblr-link-hover-color: var(--kw-primary-hover);
}
html[data-theme-pair] .bg-primary-lt {
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
}
html[data-theme-pair] .alert-primary {
--tblr-alert-color: var(--kw-primary);
--tblr-alert-bg: rgba(var(--tblr-primary-rgb), 0.07);
--tblr-alert-border-color: rgba(var(--tblr-primary-rgb), 0.15);
}
html[data-theme-pair] .form-check-input:checked {
background-color: var(--kw-primary);
border-color: var(--kw-primary);
}
html[data-theme-pair] .form-select:focus,
html[data-theme-pair] .form-control:focus {
border-color: var(--kw-primary);
box-shadow: 0 0 0 0.25rem rgba(var(--tblr-primary-rgb), 0.25);
}
html[data-theme-pair] .nav-tabs .nav-link.active {
border-bottom-color: var(--kw-primary);
}
/* Override Tabler hardcoded blue badges to use theme accent */
html[data-theme-pair] .bg-blue-lt {
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
color: var(--kw-primary) !important;
}
html[data-theme-pair] .bg-azure-lt {
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
color: var(--kw-primary) !important;
}
html[data-theme-pair] .bg-cyan-lt {
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
color: var(--kw-primary) !important;
}
html[data-theme-pair] .text-primary {
color: var(--kw-primary) !important;
}
html[data-theme-pair] .text-blue {
color: var(--kw-primary) !important;
}
html[data-theme-pair] .text-azure {
color: var(--kw-primary) !important;
}
html[data-theme-pair] .text-cyan {
color: var(--kw-primary) !important;
}
/* ── Ocean Theme ── */
html[data-theme-pair="ocean"] {
--kw-primary: #0891b2; --kw-primary-hover: #0e7490; --kw-primary-active: #155e75;
--tblr-primary: #0891b2; --tblr-primary-rgb: 8,145,178;
}
html[data-theme-pair="ocean"][data-bs-theme="dark"] {
--kw-primary: #06b6d4; --kw-primary-hover: #0891b2; --kw-primary-active: #0e7490;
--tblr-primary: #06b6d4; --tblr-primary-rgb: 6,182,212;
--tblr-bg-surface: #0f2035; --tblr-bg-surface-secondary: #122840;
--tblr-bg-surface-tertiary: #0c1e30; --tblr-bg-surface-dark: #0c1a2a;
--tblr-bg-forms: #0c1e30; --tblr-body-bg: #0c1a2a; --tblr-body-bg-rgb: 12,26,42;
--tblr-border-color: #1a3555; --tblr-border-color-translucent: rgba(6, 182, 212, 0.12);
}
html[data-theme-pair="ocean"][data-bs-theme="light"],
html[data-theme-pair="ocean"][data-bs-theme="light"] body { background-color: #ecfeff !important; }
html[data-theme-pair="ocean"][data-bs-theme="dark"],
html[data-theme-pair="ocean"][data-bs-theme="dark"] body { background-color: #0c1a2a !important; }
html[data-theme-pair="ocean"][data-bs-theme="light"] .navbar-vertical,
html[data-theme-pair="ocean"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #155e75 !important; }
html[data-theme-pair="ocean"][data-bs-theme="dark"] .navbar-vertical,
html[data-theme-pair="ocean"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #071220 !important; }
html[data-theme-pair="ocean"][data-bs-theme="light"] .page-wrapper { background: #ecfeff; }
html[data-theme-pair="ocean"][data-bs-theme="dark"] .page-wrapper,
html[data-theme-pair="ocean"][data-bs-theme="dark"] .page-body { background: #0c1a2a; }
html[data-theme-pair="ocean"] .keywarden-header-brand .keywarden-brand i.ti { color: #22d3ee; }
html[data-theme-pair="ocean"] .nav-category { color: rgba(160, 220, 230, 0.6); }
html[data-theme-pair="ocean"][data-bs-theme="light"] ::selection { background: #a5f3fc; color: #155e75; }
html[data-theme-pair="ocean"][data-bs-theme="light"] ::-moz-selection { background: #a5f3fc; color: #155e75; }
html[data-theme-pair="ocean"][data-bs-theme="dark"] ::selection { background: #164e63; color: #ecfeff; }
html[data-theme-pair="ocean"][data-bs-theme="dark"] ::-moz-selection { background: #164e63; color: #ecfeff; }
/* ── Forest Theme ── */
html[data-theme-pair="forest"] {
--kw-primary: #16a34a; --kw-primary-hover: #15803d; --kw-primary-active: #166534;
--tblr-primary: #16a34a; --tblr-primary-rgb: 22,163,74;
}
html[data-theme-pair="forest"][data-bs-theme="dark"] {
--kw-primary: #4ade80; --kw-primary-hover: #22c55e; --kw-primary-active: #16a34a;
--tblr-primary: #4ade80; --tblr-primary-rgb: 74,222,128;
--tblr-bg-surface: #0f2216; --tblr-bg-surface-secondary: #122a1b;
--tblr-bg-surface-tertiary: #0c1d12; --tblr-bg-surface-dark: #0a1a10;
--tblr-bg-forms: #0c1d12; --tblr-body-bg: #0a1a10; --tblr-body-bg-rgb: 10,26,16;
--tblr-border-color: #1a3524; --tblr-border-color-translucent: rgba(74, 222, 128, 0.10);
}
html[data-theme-pair="forest"][data-bs-theme="light"],
html[data-theme-pair="forest"][data-bs-theme="light"] body { background-color: #f0fdf4 !important; }
html[data-theme-pair="forest"][data-bs-theme="dark"],
html[data-theme-pair="forest"][data-bs-theme="dark"] body { background-color: #0a1a10 !important; }
html[data-theme-pair="forest"][data-bs-theme="light"] .navbar-vertical,
html[data-theme-pair="forest"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #14532d !important; }
html[data-theme-pair="forest"][data-bs-theme="dark"] .navbar-vertical,
html[data-theme-pair="forest"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #061209 !important; }
html[data-theme-pair="forest"][data-bs-theme="light"] .page-wrapper { background: #f0fdf4; }
html[data-theme-pair="forest"][data-bs-theme="dark"] .page-wrapper,
html[data-theme-pair="forest"][data-bs-theme="dark"] .page-body { background: #0a1a10; }
html[data-theme-pair="forest"] .keywarden-header-brand .keywarden-brand i.ti { color: #4ade80; }
html[data-theme-pair="forest"] .nav-category { color: rgba(160, 210, 170, 0.6); }
html[data-theme-pair="forest"][data-bs-theme="light"] ::selection { background: #bbf7d0; color: #14532d; }
html[data-theme-pair="forest"][data-bs-theme="light"] ::-moz-selection { background: #bbf7d0; color: #14532d; }
html[data-theme-pair="forest"][data-bs-theme="dark"] ::selection { background: #166534; color: #f0fdf4; }
html[data-theme-pair="forest"][data-bs-theme="dark"] ::-moz-selection { background: #166534; color: #f0fdf4; }
/* ── Sunset Theme ── */
html[data-theme-pair="sunset"] {
--kw-primary: #d97706; --kw-primary-hover: #b45309; --kw-primary-active: #92400e;
--tblr-primary: #d97706; --tblr-primary-rgb: 217,119,6;
}
html[data-theme-pair="sunset"][data-bs-theme="dark"] {
--kw-primary: #f59e0b; --kw-primary-hover: #d97706; --kw-primary-active: #b45309;
--tblr-primary: #f59e0b; --tblr-primary-rgb: 245,158,11;
--tblr-bg-surface: #221a0e; --tblr-bg-surface-secondary: #281f12;
--tblr-bg-surface-tertiary: #1e170a; --tblr-bg-surface-dark: #1a1408;
--tblr-bg-forms: #1e170a; --tblr-body-bg: #1a1408; --tblr-body-bg-rgb: 26,20,8;
--tblr-border-color: #3a2c14; --tblr-border-color-translucent: rgba(245, 158, 11, 0.10);
}
html[data-theme-pair="sunset"][data-bs-theme="light"],
html[data-theme-pair="sunset"][data-bs-theme="light"] body { background-color: #fffbeb !important; }
html[data-theme-pair="sunset"][data-bs-theme="dark"],
html[data-theme-pair="sunset"][data-bs-theme="dark"] body { background-color: #1a1408 !important; }
html[data-theme-pair="sunset"][data-bs-theme="light"] .navbar-vertical,
html[data-theme-pair="sunset"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #78350f !important; }
html[data-theme-pair="sunset"][data-bs-theme="dark"] .navbar-vertical,
html[data-theme-pair="sunset"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #110d04 !important; }
html[data-theme-pair="sunset"][data-bs-theme="light"] .page-wrapper { background: #fffbeb; }
html[data-theme-pair="sunset"][data-bs-theme="dark"] .page-wrapper,
html[data-theme-pair="sunset"][data-bs-theme="dark"] .page-body { background: #1a1408; }
html[data-theme-pair="sunset"] .keywarden-header-brand .keywarden-brand i.ti { color: #fbbf24; }
html[data-theme-pair="sunset"] .nav-category { color: rgba(220, 190, 150, 0.6); }
html[data-theme-pair="sunset"][data-bs-theme="light"] ::selection { background: #fde68a; color: #78350f; }
html[data-theme-pair="sunset"][data-bs-theme="light"] ::-moz-selection { background: #fde68a; color: #78350f; }
html[data-theme-pair="sunset"][data-bs-theme="dark"] ::selection { background: #92400e; color: #fffbeb; }
html[data-theme-pair="sunset"][data-bs-theme="dark"] ::-moz-selection { background: #92400e; color: #fffbeb; }
/* ── Rose Theme ── */
html[data-theme-pair="rose"] {
--kw-primary: #db2777; --kw-primary-hover: #be185d; --kw-primary-active: #9d174d;
--tblr-primary: #db2777; --tblr-primary-rgb: 219,39,119;
}
html[data-theme-pair="rose"][data-bs-theme="dark"] {
--kw-primary: #f472b6; --kw-primary-hover: #ec4899; --kw-primary-active: #db2777;
--tblr-primary: #f472b6; --tblr-primary-rgb: 244,114,182;
--tblr-bg-surface: #22101a; --tblr-bg-surface-secondary: #281420;
--tblr-bg-surface-tertiary: #1e0c16; --tblr-bg-surface-dark: #1a0a14;
--tblr-bg-forms: #1e0c16; --tblr-body-bg: #1a0a14; --tblr-body-bg-rgb: 26,10,20;
--tblr-border-color: #3a1a2c; --tblr-border-color-translucent: rgba(244, 114, 182, 0.10);
}
html[data-theme-pair="rose"][data-bs-theme="light"],
html[data-theme-pair="rose"][data-bs-theme="light"] body { background-color: #fdf2f8 !important; }
html[data-theme-pair="rose"][data-bs-theme="dark"],
html[data-theme-pair="rose"][data-bs-theme="dark"] body { background-color: #1a0a14 !important; }
html[data-theme-pair="rose"][data-bs-theme="light"] .navbar-vertical,
html[data-theme-pair="rose"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #831843 !important; }
html[data-theme-pair="rose"][data-bs-theme="dark"] .navbar-vertical,
html[data-theme-pair="rose"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #12060e !important; }
html[data-theme-pair="rose"][data-bs-theme="light"] .page-wrapper { background: #fdf2f8; }
html[data-theme-pair="rose"][data-bs-theme="dark"] .page-wrapper,
html[data-theme-pair="rose"][data-bs-theme="dark"] .page-body { background: #1a0a14; }
html[data-theme-pair="rose"] .keywarden-header-brand .keywarden-brand i.ti { color: #f472b6; }
html[data-theme-pair="rose"] .nav-category { color: rgba(220, 160, 190, 0.6); }
html[data-theme-pair="rose"][data-bs-theme="light"] ::selection { background: #fbcfe8; color: #831843; }
html[data-theme-pair="rose"][data-bs-theme="light"] ::-moz-selection { background: #fbcfe8; color: #831843; }
html[data-theme-pair="rose"][data-bs-theme="dark"] ::selection { background: #9f1239; color: #fdf2f8; }
html[data-theme-pair="rose"][data-bs-theme="dark"] ::-moz-selection { background: #9f1239; color: #fdf2f8; }
/* ── Nord Theme ── */
html[data-theme-pair="nord"] {
--kw-primary: #5e81ac; --kw-primary-hover: #4c6e96; --kw-primary-active: #3b5b80;
--tblr-primary: #5e81ac; --tblr-primary-rgb: 94,129,172;
}
html[data-theme-pair="nord"][data-bs-theme="dark"] {
--kw-primary: #88c0d0; --kw-primary-hover: #6eb0c2; --kw-primary-active: #5e9fb4;
--tblr-primary: #88c0d0; --tblr-primary-rgb: 136,192,208;
--tblr-bg-surface: #242830; --tblr-bg-surface-secondary: #2a2e36;
--tblr-bg-surface-tertiary: #21252c; --tblr-bg-surface-dark: #1e2128;
--tblr-bg-forms: #21252c; --tblr-body-bg: #1e2128; --tblr-body-bg-rgb: 30,33,40;
--tblr-border-color: #353a44; --tblr-border-color-translucent: rgba(136, 192, 208, 0.10);
}
html[data-theme-pair="nord"][data-bs-theme="light"],
html[data-theme-pair="nord"][data-bs-theme="light"] body { background-color: #eceff4 !important; }
html[data-theme-pair="nord"][data-bs-theme="dark"],
html[data-theme-pair="nord"][data-bs-theme="dark"] body { background-color: #1e2128 !important; }
html[data-theme-pair="nord"][data-bs-theme="light"] .navbar-vertical,
html[data-theme-pair="nord"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #2e3440 !important; }
html[data-theme-pair="nord"][data-bs-theme="dark"] .navbar-vertical,
html[data-theme-pair="nord"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #14171c !important; }
html[data-theme-pair="nord"][data-bs-theme="light"] .page-wrapper { background: #eceff4; }
html[data-theme-pair="nord"][data-bs-theme="dark"] .page-wrapper,
html[data-theme-pair="nord"][data-bs-theme="dark"] .page-body { background: #1e2128; }
html[data-theme-pair="nord"] .keywarden-header-brand .keywarden-brand i.ti { color: #88c0d0; }
html[data-theme-pair="nord"] .nav-category { color: rgba(160, 180, 200, 0.6); }
html[data-theme-pair="nord"][data-bs-theme="light"] ::selection { background: #d8dee9; color: #2e3440; }
html[data-theme-pair="nord"][data-bs-theme="light"] ::-moz-selection { background: #d8dee9; color: #2e3440; }
html[data-theme-pair="nord"][data-bs-theme="dark"] ::selection { background: #434c5e; color: #eceff4; }
html[data-theme-pair="nord"][data-bs-theme="dark"] ::-moz-selection { background: #434c5e; color: #eceff4; }
</style>
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
<link rel="stylesheet" href="/static/css/tabler.min.css">
@@ -344,6 +582,15 @@
</div>
<!-- Spacer -->
<div class="flex-grow-1"></div>
<!-- Update Available Badge (Admin/Owner only) -->
{{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}}
<div class="nav-item d-none d-md-flex me-2">
<a href="{{releaseURL}}" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Update verfügbar: {{latestVersion}} (aktuell: {{appVersion}})">
<i class="ti ti-download" style="color: #fbbf24;"></i>
<span style="color: #fbbf24;">Update {{latestVersion}}</span>
</a>
</div>
{{end}}{{end}}
<!-- Repository Link -->
<div class="nav-item d-none d-md-flex me-2">
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
@@ -558,14 +805,36 @@
<script src="/static/js/tabler.min.js"></script>
<script>
// --- Theme Toggle ---
function getResolvedTheme() {
var stored = document.documentElement.getAttribute('data-bs-theme');
return stored || 'light';
function parseTheme(theme) {
if (!theme || theme === 'auto' || theme === 'light' || theme === 'dark') {
return { pair: 'default', mode: theme || 'auto' };
}
var idx = theme.lastIndexOf('-');
if (idx > 0) {
return { pair: theme.substring(0, idx), mode: theme.substring(idx + 1) };
}
return { pair: 'default', mode: 'auto' };
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
document.documentElement.style.colorScheme = theme;
updateThemeIcon(theme);
function resolveMode(mode) {
if (mode !== 'light' && mode !== 'dark') {
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
return mode;
}
function getResolvedTheme() {
return document.documentElement.getAttribute('data-bs-theme') || 'light';
}
function applyFullTheme(themeStr) {
var parts = parseTheme(themeStr);
var resolved = resolveMode(parts.mode);
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
if (parts.pair !== 'default') {
document.documentElement.setAttribute('data-theme-pair', parts.pair);
} else {
document.documentElement.removeAttribute('data-theme-pair');
}
updateThemeIcon(resolved);
}
function updateThemeIcon(theme) {
var icon = document.getElementById('theme-icon');
@@ -573,15 +842,18 @@
icon.className = theme === 'dark' ? 'ti ti-moon' : 'ti ti-sun';
}
function toggleTheme() {
var current = getResolvedTheme();
var next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
// Persist choice via API (fire-and-forget)
var raw = window.__kwThemeRaw || 'auto';
var parts = parseTheme(raw);
var currentMode = getResolvedTheme();
var nextMode = currentMode === 'dark' ? 'light' : 'dark';
var nextTheme = parts.pair === 'default' ? nextMode : parts.pair + '-' + nextMode;
window.__kwThemeRaw = nextTheme;
applyFullTheme(nextTheme);
var csrf = (document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/) || [])[1] || '';
fetch('/settings/theme', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'theme=' + encodeURIComponent(next) + '&_csrf=' + encodeURIComponent(csrf)
body: 'theme=' + encodeURIComponent(nextTheme) + '&_csrf=' + encodeURIComponent(csrf)
});
}
// Set initial icon on page load

View File

@@ -11,10 +11,32 @@
<div class="row align-items-end">
<div class="col-auto">
<label class="form-label">Theme</label>
<select name="theme" class="form-select" style="width: 250px;">
<option value="auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto")}}selected{{end}}>Automatic (System)</option>
<option value="light" {{if and .User (eq .User.Theme "light")}}selected{{end}}>Light</option>
<option value="dark" {{if and .User (eq .User.Theme "dark")}}selected{{end}}>Dark</option>
<select name="theme" class="form-select" style="width: 280px;">
<optgroup label="🌊 Ocean (Standard)">
<option value="ocean-auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto") (eq .User.Theme "ocean-auto")}}selected{{end}}>Ocean (System)</option>
<option value="ocean-light" {{if or (and .User (eq .User.Theme "ocean-light")) (and .User (eq .User.Theme "light"))}}selected{{end}}>Ocean Light</option>
<option value="ocean-dark" {{if or (and .User (eq .User.Theme "ocean-dark")) (and .User (eq .User.Theme "dark"))}}selected{{end}}>Ocean Dark</option>
</optgroup>
<optgroup label="🌲 Forest">
<option value="forest-auto" {{if and .User (eq .User.Theme "forest-auto")}}selected{{end}}>Forest (System)</option>
<option value="forest-light" {{if and .User (eq .User.Theme "forest-light")}}selected{{end}}>Forest Light</option>
<option value="forest-dark" {{if and .User (eq .User.Theme "forest-dark")}}selected{{end}}>Forest Dark</option>
</optgroup>
<optgroup label="🌅 Sunset">
<option value="sunset-auto" {{if and .User (eq .User.Theme "sunset-auto")}}selected{{end}}>Sunset (System)</option>
<option value="sunset-light" {{if and .User (eq .User.Theme "sunset-light")}}selected{{end}}>Sunset Light</option>
<option value="sunset-dark" {{if and .User (eq .User.Theme "sunset-dark")}}selected{{end}}>Sunset Dark</option>
</optgroup>
<optgroup label="🌹 Rose">
<option value="rose-auto" {{if and .User (eq .User.Theme "rose-auto")}}selected{{end}}>Rose (System)</option>
<option value="rose-light" {{if and .User (eq .User.Theme "rose-light")}}selected{{end}}>Rose Light</option>
<option value="rose-dark" {{if and .User (eq .User.Theme "rose-dark")}}selected{{end}}>Rose Dark</option>
</optgroup>
<optgroup label="❄️ Nord">
<option value="nord-auto" {{if and .User (eq .User.Theme "nord-auto")}}selected{{end}}>Nord (System)</option>
<option value="nord-light" {{if and .User (eq .User.Theme "nord-light")}}selected{{end}}>Nord Light</option>
<option value="nord-dark" {{if and .User (eq .User.Theme "nord-dark")}}selected{{end}}>Nord Dark</option>
</optgroup>
</select>
</div>
<div class="col-auto">

View File

@@ -9,6 +9,17 @@
<div class="card-body">
{{with .SystemInfo}}
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Keywarden Version</div>
<div class="datagrid-content">
<span class="badge bg-blue-lt">{{appVersion}}</span>
{{if updateAvailable}}
<a href="{{releaseURL}}" target="_blank" rel="noopener noreferrer" class="badge bg-yellow-lt ms-1" title="Update verfügbar">
<i class="ti ti-download"></i> {{latestVersion}} verfügbar
</a>
{{end}}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Runtime Environment</div>
<div class="datagrid-content">

View File

@@ -78,11 +78,13 @@
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
<i class="ti ti-edit"></i>
</a>
{{if ne .ID $.InitialOwnerID}}
<form method="POST" action="/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this user?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
{{end}}
</div>
</td>
</tr>

View File

@@ -38,6 +38,13 @@
</div>
<div class="mb-3">
<label class="form-label required">Role</label>
{{if .IsInitialOwner}}
<select name="role" class="form-select" disabled>
<option value="owner" selected>Owner</option>
</select>
<input type="hidden" name="role" value="owner">
<small class="form-hint text-warning"><i class="ti ti-shield-lock"></i> The initial owner role cannot be changed. This account was created during installation and is permanently protected.</small>
{{else}}
<select name="role" class="form-select">
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
{{with $.User}}
@@ -47,6 +54,7 @@
{{end}}
{{end}}
</select>
{{end}}
</div>
<div class="mb-3">
<label class="form-label">MFA Status</label>