18 Commits

Author SHA1 Message Date
09b3333571 Release: v0.4.0-alpha
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 8m31s
Security Scan / Go Vulnerability Check (pull_request) Successful in 8m3s
2026-04-09 22:38:46 +02:00
d8cb1a2f97 Update dashboard screenshot
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-09 22:34:00 +02:00
789ef6f10f fix: align upload button layout in login background image section 2026-04-09 22:27:23 +02:00
c2b96563cb remove: login card style dropdown and subtitle setting, glass effect now always active 2026-04-09 22:20:36 +02:00
b4424b1e64 feat: auto-detect login text color based on background image brightness 2026-04-09 22:09:30 +02:00
2f55ec84b8 feat: add glassmorphism effect to all cards, header, sidebar and standalone pages 2026-04-09 21:41:42 +02:00
07ea917726 fix: master key deploy now logged in deployment history, add flash feedback after deploy 2026-04-09 15:13:41 +02:00
da6d66e048 feat: add TZ timezone support for all displayed timestamps 2026-04-09 13:37:51 +02:00
c15bac108d feat: rework footer – remove license, link Keywarden to keywarden.app, link version to releases page 2026-04-08 23:24:17 +02:00
b665e623f9 feat: add login page customization (background image, glass card style, subtitle) 2026-04-08 23:12:30 +02:00
dae6c6ae02 fix: add icon text spacing to standalone pages 2026-04-08 22:44:28 +02:00
ce36939d31 docs: update architecture, security and contributing docs for gzip and font subsetting 2026-04-08 22:32:51 +02:00
8a10981ecc perf: subset tabler-icons font/CSS to used icons only (801KB -> 18KB woff2, 217KB -> 4KB CSS)
- Remove unused .ttf and .woff font files
- Fix preload URL mismatch causing double font download
- Remove content-visibility: auto (Firefox freeze on tab hover)
- Add font-display: swap for non-blocking font loading
- Add tools/subset-icons.py for future icon subsetting
2026-04-08 22:32:33 +02:00
34ce8a8fc3 feat: add gzip compression middleware for HTTP responses 2026-04-08 22:32:12 +02:00
3a860914d5 fix: auto-detect version from version.go in Dockerfile, pass git tag in CI release 2026-04-08 21:37:49 +02:00
dd4af5b25c feat: allow owner to deploy system master key from deploy page 2026-04-08 21:37:27 +02:00
1cf7f50bfb feat: centralize version in internal/version package, fix updater for pre-release tags, show version in footer 2026-04-08 21:10:07 +02:00
ca402eb88e fix: harden auth timing, cookie attrs, password gen bias, email template escaping; add security tests 2026-04-08 20:45:16 +02:00
55 changed files with 1747 additions and 137 deletions

View File

@@ -19,6 +19,11 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
# Log level: ERROR, WARN, INFO (default), DEBUG, TRACE
KEYWARDEN_LOG_LEVEL=INFO
# --- Timezone ---
# IANA timezone name (e.g. Europe/Berlin, America/New_York).
# Affects all displayed timestamps in the UI.
TZ=Europe/Berlin
# --- Paths (optional, Docker defaults are usually fine) ---
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
# The Dockerfile already sets correct defaults (/data/...). Only override

View File

@@ -59,6 +59,8 @@ jobs:
with:
context: .
push: true
build-args: |
VERSION=${{ steps.version.outputs.tag }}
tags: |
${{ steps.version.outputs.registry }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:latest
${{ steps.version.outputs.registry }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@ vendor/
# AI workspace
.ki-workspace/
# Python (tools/subset-icons.py)
.venv/
# OS
.DS_Store
Thumbs.db

View File

@@ -12,8 +12,12 @@ RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X main.Version=${VERSION}" ./cmd/keywarden/
ARG VERSION=""
RUN set -e; \
if [ -z "$VERSION" ]; then \
VERSION=$(grep 'var Version' internal/version/version.go | sed 's/.*"\(.*\)".*/\1/'); \
fi; \
CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X git.techniverse.net/scriptos/keywarden/internal/version.Version=${VERSION}" ./cmd/keywarden/
# Stage 2: Runtime
FROM alpine:3.21
@@ -36,6 +40,7 @@ ENV KEYWARDEN_DATA_DIR=/data
ENV KEYWARDEN_KEYS_DIR=/data/keys
ENV KEYWARDEN_MASTER_DIR=/data/master
ENV KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
ENV TZ=UTC
EXPOSE 8080

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
@@ -25,17 +26,11 @@ import (
"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/version"
"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 {
@@ -52,11 +47,15 @@ func main() {
// Load config first (needed for log level)
cfg := config.Load()
// Set application-wide timezone from TZ environment variable
time.Local = cfg.Timezone
// Initialize structured logging
logging.Init(cfg.LogLevel)
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", Version)
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", version.Version)
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
logging.Info("Timezone: %s", cfg.Timezone)
// Validate data paths relative paths inside a container bypass the
// persistent volume mount and lead to silent data loss on restart.
@@ -126,7 +125,7 @@ func main() {
}
// Initialize update checker
updaterSvc := updater.NewService(Version)
updaterSvc := updater.NewService(version.Version)
// Setup HTTP handlers
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL, updaterSvc)
@@ -139,6 +138,7 @@ func main() {
// Build middleware chain (innermost → outermost)
var h http.Handler = mux
h = security.GzipMiddleware()(h)
h = security.CSRFMiddleware(cfg.SecureCookies)(h)
h = security.SizeLimitMiddleware(cfg.MaxRequestSize)(h)
h = security.RateLimitMiddleware(cfg.RateLimitLogin)(h)
@@ -293,7 +293,7 @@ func handleResetPassword(args []string) {
// printUsage displays available CLI subcommands
func printUsage() {
fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", Version)
fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", version.Version)
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" keywarden Start the server")

View File

@@ -59,9 +59,12 @@ Server groups are used as targets for:
1. Navigate to **Deploy**
2. Select an **SSH key** from the dropdown (shows all keys from all users)
3. Select a **target server**
4. Click **Deploy**
4. Choose an authentication method (password or existing key)
5. Click **Deploy**
Keywarden connects to the target server using the system master key and appends the selected public key to the server user's `~/.ssh/authorized_keys`.
Keywarden connects to the target server and appends the selected public key to the server user's `~/.ssh/authorized_keys`.
> **Owner only:** The SSH key dropdown includes the **[MASTER] System Master Key** as the first option. This allows the owner to deploy the system master key directly to servers from the Deploy page — useful for initial server setup or re-deployment after master key regeneration.
### Group Deployment
@@ -74,7 +77,9 @@ The key is deployed to all servers in the group sequentially.
### Deployment History
The deploy page shows the last 50 deployment results with status (success/failed) and error messages.
The deploy page shows the last 50 deployment results with status (success/failed) and error messages. Master key deployments are included in the history as **[MASTER] System Master Key**.
After each deployment (single host or group), a flash message is displayed at the top of the page indicating success or failure.
## Access Assignments
@@ -220,7 +225,7 @@ Navigate to **System** to view runtime information:
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.
The update checker is only active when the application was built with a proper version tag. Development builds without a version skip the check entirely.
## Admin Settings (Owner Only)
@@ -228,6 +233,10 @@ See [Roles & Permissions](roles.md) for details on which settings are owner-only
Navigate to **Admin Settings** (owner only) to configure:
### Login Page Customization
- **Background Image** — Upload a custom background image for the login page (max 5 MB, JPEG/PNG/WebP). The image is centered and fills the screen without distortion (`background-size: cover`). The text color (heading, subtitle, footer) is automatically adjusted based on the image brightness — light text for dark images, dark text for bright images. The login card always uses a glass effect (transparent, blurred backdrop).
### Application Settings
- **App Name** — Custom application name displayed in the UI

View File

@@ -36,7 +36,7 @@ internal/
logging/ ← Structured logging with levels
mail/ ← SMTP email service (notifications, invitations)
models/ ← Data models (User, SSHKey, Server, etc.)
security/ ← CSRF, security headers, rate limiting, proxy detection
security/ ← CSRF, security headers, rate limiting, gzip compression, proxy detection
servers/ ← Server and server group management, access assignments
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
updater/ ← Background update checker (Gitea releases API)
@@ -59,7 +59,7 @@ web/
8. **Configure security** subsystem (trusted proxy parsing)
9. **Set up HTTP routes** and load templates
10. **Start session cleanup** goroutine (removes expired sessions every minute)
11. **Apply middleware chain**: request logger → security headers → rate limiting → size limiting → CSRF
11. **Apply middleware chain**: request logger → security headers → rate limiting → size limiting → CSRF → gzip compression
12. **Start cron scheduler** (checks for pending jobs every 30 seconds)
13. **Start key enforcement worker** (if enabled in Admin Settings)
14. **Start HTTP server**
@@ -95,6 +95,7 @@ Client → [Nginx/Caddy] → Keywarden HTTP Server
├── Rate Limit Middleware (login endpoints)
├── Size Limit Middleware
├── CSRF Middleware (double-submit cookie)
├── Gzip Compression Middleware
├── Public Routes (/login, /invite/*)
├── Auth Routes (requireAuth → all authenticated users)

View File

@@ -31,6 +31,8 @@ Keywarden provides a built-in encrypted backup and restore feature for the entir
| Audit Log | ✅ |
| Application Settings | ✅ |
> **Note:** Uploaded branding assets (e.g., custom login background images in `data/branding/`) are stored as files and are **not** included in the `.kwbak` database backup. Use a Docker volume backup to include these files.
> **Note:** SSH private keys are stored with double encryption in backups — first with the application's `KEYWARDEN_ENCRYPTION_KEY`, then with the backup password. Both keys are needed to access the private keys.
## Importing a Backup

View File

@@ -43,7 +43,7 @@ go mod download
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/
CGO_ENABLED=1 go build -ldflags="-X 'git.techniverse.net/scriptos/keywarden/internal/version.Version=v1.0.0'" -o keywarden ./cmd/keywarden/
# Run
./keywarden
@@ -82,6 +82,7 @@ keywarden/
│ │ ├── csrf.go # CSRF double-submit cookie middleware
│ │ ├── headers.go # Security headers middleware (CSP, X-Frame-Options, etc.)
│ │ ├── proxy.go # Trusted proxy IP extraction
│ │ ├── gzip.go # Gzip compression middleware
│ │ ├── ratelimit.go # IP-based rate limiting middleware
│ │ └── sizelimit.go # Request body size limit middleware
│ ├── servers/servers.go # Server and group management, access assignments
@@ -91,6 +92,9 @@ keywarden/
│ ├── embed.go # Go embed directives
│ ├── static/ # CSS, JS, fonts (Tabler UI)
│ └── templates/ # HTML templates
├── tools/
│ ├── subset-icons.py # Tabler Icons font/CSS subset tool
│ └── tabler-icons-full/ # Full Tabler Icons source files
├── docs/ # Documentation
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose configuration

View File

@@ -46,7 +46,7 @@ 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.
The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. If omitted, the version is automatically extracted from `internal/version/version.go`. The CI release pipeline passes the Git tag as `VERSION` automatically.
### Docker Compose
@@ -98,6 +98,9 @@ KEYWARDEN_ENCRYPTION_KEY=generate-another-random-string-32-chars
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Timezone (IANA, e.g. Europe/Berlin, America/New_York)
TZ=UTC
# Initial owner (only used on first startup)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
@@ -132,6 +135,7 @@ All persistent data is stored in the `/data` volume:
| `/data/keys/` | Reserved for future use |
| `/data/master/` | Reserved for future use |
| `/data/avatars/` | User profile pictures |
| `/data/branding/` | Login page branding assets (background images) |
> **Important:** The SQLite database contains encrypted private keys. Back up the `/data` volume regularly. See [Backup & Restore](backup-restore.md).

View File

@@ -12,6 +12,7 @@ Complete reference of all configuration options for Keywarden. All settings are
| `KEYWARDEN_KEYS_DIR` | `./data/keys` | Directory for key storage (reserved) |
| `KEYWARDEN_MASTER_DIR` | `./data/master` | Directory for master key storage (reserved) |
| `KEYWARDEN_LOG_LEVEL` | `INFO` | Log level: `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE` |
| `TZ` | `UTC` | Timezone for all displayed timestamps (e.g., `Europe/Berlin`, `America/New_York`). Uses standard IANA timezone names. |
## Security
@@ -60,6 +61,7 @@ When running in the Docker container, these defaults are set in the Dockerfile:
| `KEYWARDEN_DATA_DIR` | `/data` |
| `KEYWARDEN_KEYS_DIR` | `/data/keys` |
| `KEYWARDEN_MASTER_DIR` | `/data/master` |
| `TZ` | `UTC` |
## Example .env File
@@ -76,6 +78,9 @@ KEYWARDEN_ENCRYPTION_KEY=mX9nP2qR4sT6uV8wY0zA1bC3dE5fG7hI
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Timezone (IANA timezone name, e.g. Europe/Berlin)
TZ=Europe/Berlin
# Initial owner (only used on first startup)
KEYWARDEN_OWNER_USER=admin
KEYWARDEN_OWNER_EMAIL=admin@example.com
@@ -115,3 +120,4 @@ In addition to environment variables, the following settings are configured thro
| `lockout_attempts` | `5` | Failed login attempts before lockout (0 = disabled) |
| `lockout_duration` | `15` | Lockout duration in minutes |
| `mfa_required` | `false` | Enforce MFA for all users |
| `login_text_color` | `light` | Login text color over background image: `light` or `dark` (auto-detected on upload) |

View File

@@ -35,6 +35,7 @@ Owner → Admin → User
| Test server connectivity | ❌ | ✅ | ✅ |
| **Deployments** | | | |
| Manual key deployment | ❌ | ✅ | ✅ |
| Deploy system master key | ❌ | ❌ | ✅ |
| Group deployment | ❌ | ✅ | ✅ |
| **Access Assignments** | | | |
| Create/edit/delete assignments | ❌ | ✅ | ✅ |
@@ -88,6 +89,7 @@ Admins **cannot** access the Admin Settings page, regenerate the master key, man
The **Owner** role has unrestricted access. In addition to all Admin permissions, the owner can:
- Deploy the system master key to servers (via the Deploy page)
- Access the Admin Settings page
- Configure application settings (app name, session timeout, default key type)
- Configure security settings (password policy, account lockout, MFA enforcement)

View File

@@ -141,6 +141,12 @@ Login endpoints (`POST /login`, `POST /login/mfa`) are rate-limited per IP addre
A background goroutine cleans up expired rate limit entries every 5 minutes.
## Gzip Compression
HTTP responses are compressed using gzip for clients that send `Accept-Encoding: gzip`. Only compressible content types are compressed (HTML, CSS, JS, JSON, SVG). Already-compressed formats (woff2, images) are passed through unchanged.
The middleware uses a `sync.Pool` of gzip writers for efficient memory reuse.
## Request Size Limiting
Request bodies are limited to prevent denial-of-service via large uploads.
@@ -180,7 +186,7 @@ WARN: KEYWARDEN_TRUSTED_PROXIES not set proxy headers (X-Forwarded-For) are
- Cookie name: `keywarden_session`
- Cookie flags:
- `HttpOnly` — Not accessible via JavaScript
- `SameSite=Lax` — Prevents CSRF from external sites
- `SameSite=Strict` — Prevents CSRF from external sites
- `Secure` — Only over HTTPS (when enabled)
- `MaxAge=86400` — 24 hours
- Sessions stored in-memory (not persisted across restarts)

View File

@@ -134,7 +134,7 @@ If email is configured, you can enable **Login Notifications**. Every time someo
### Profile Picture
Upload a profile picture (avatar) that is displayed next to your name in the navigation. Supported formats: JPEG, PNG, GIF, WebP. Maximum size is limited by the server's request size limit.
Upload a profile picture (avatar) that is displayed next to your name in the navigation. Supported formats: JPEG, PNG, GIF, WebP. Maximum file size: 5 MB.
## Audit Log

View File

@@ -5,6 +5,12 @@
set -e
# Configure timezone if TZ is set (requires tzdata package)
if [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
fi
# Create data directories (bind-mount from host may be owned by root)
mkdir -p /data/keys /data/master /data/avatars

5
go.mod
View File

@@ -8,4 +8,7 @@ require (
golang.org/x/crypto v0.49.0
)
require golang.org/x/sys v0.42.0 // indirect
require (
golang.org/x/image v0.39.0 // indirect
golang.org/x/sys v0.42.0 // indirect
)

2
go.sum
View File

@@ -4,6 +4,8 @@ github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmIT
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=

View File

@@ -61,6 +61,7 @@ const (
ActionMasterKeyRegenerated = "masterkey_regenerated"
ActionMasterKeyRegenFailed = "masterkey_regen_failed"
ActionAvatarChanged = "avatar_changed"
ActionBrandingChanged = "branding_changed"
// Email
ActionEmailNotifyChanged = "email_notify_changed"

View File

@@ -77,6 +77,10 @@ func (s *Service) Register(username, email, password, role string, mustChangePas
}, nil
}
// dummyHash is a pre-computed bcrypt hash used for constant-time comparison
// when a user is not found, preventing timing-based username enumeration.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-constant-time-padding"), bcrypt.DefaultCost)
// Login authenticates a user and returns the user if successful
func (s *Service) Login(username, password string) (*models.User, error) {
user := &models.User{}
@@ -86,6 +90,10 @@ func (s *Service) Login(username, password string) (*models.User, error) {
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
// Perform a dummy bcrypt comparison to prevent timing-based username enumeration.
// Without this, an attacker could distinguish "user not found" (fast) from
// "wrong password" (slow due to bcrypt) by measuring response time.
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return nil, ErrInvalidCredentials
}
if err != nil {
@@ -311,17 +319,27 @@ func (s *Service) markInitialSetupComplete() {
)
}
// generateSecurePassword creates a cryptographically secure random password
// generateSecurePassword creates a cryptographically secure random password.
// It uses rejection sampling to avoid modulo bias when mapping random bytes
// to the character set.
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
const cLen = byte(len(charset)) // 62
const maxUnbiased = 256 - (256 % int(cLen)) // 252 — largest multiple of 62 that fits in a byte
result := make([]byte, length)
for i := 0; i < length; {
var b [1]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
if int(b[0]) >= maxUnbiased {
continue // reject to eliminate modulo bias
}
result[i] = charset[b[0]%cLen]
i++
}
for i := range b {
b[i] = charset[b[i]%byte(len(charset))]
}
return string(b), nil
return string(result), nil
}
// UpdateUser updates user details (admin function)

View File

@@ -0,0 +1,134 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package auth
import (
"strings"
"testing"
"unicode"
)
// ---------- generateSecurePassword ----------
func TestGenerateSecurePassword_Length(t *testing.T) {
for _, length := range []int{8, 16, 20, 32, 64} {
pw, err := generateSecurePassword(length)
if err != nil {
t.Fatalf("generateSecurePassword(%d) error: %v", length, err)
}
if len(pw) != length {
t.Fatalf("generateSecurePassword(%d) returned length %d", length, len(pw))
}
}
}
func TestGenerateSecurePassword_CharacterSet(t *testing.T) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
pw, err := generateSecurePassword(1000)
if err != nil {
t.Fatalf("generateSecurePassword error: %v", err)
}
for i, c := range pw {
if !strings.ContainsRune(charset, c) {
t.Fatalf("character at position %d (%c) not in allowed charset", i, c)
}
}
}
func TestGenerateSecurePassword_Uniqueness(t *testing.T) {
passwords := make(map[string]bool)
for i := 0; i < 100; i++ {
pw, err := generateSecurePassword(20)
if err != nil {
t.Fatalf("generateSecurePassword error: %v", err)
}
if passwords[pw] {
t.Fatal("generated duplicate password — insufficient randomness")
}
passwords[pw] = true
}
}
func TestGenerateSecurePassword_NoBias(t *testing.T) {
// Generate many characters and check that the distribution is roughly
// uniform. With rejection sampling and charset length 62, each character
// should appear about 1/62 ≈ 1.6% of the time. We use a generous margin.
const total = 62000
pw, err := generateSecurePassword(total)
if err != nil {
t.Fatalf("generateSecurePassword error: %v", err)
}
freq := make(map[rune]int)
for _, c := range pw {
freq[c]++
}
expected := float64(total) / 62.0 // ~1000
for c, count := range freq {
ratio := float64(count) / expected
// Allow 20% deviation (generous for 62k samples)
if ratio < 0.8 || ratio > 1.2 {
t.Errorf("character %c appeared %d times (expected ~%.0f, ratio %.2f) — possible bias", c, count, expected, ratio)
}
}
}
// ---------- dummyHash (timing attack prevention) ----------
func TestDummyHash_IsValid(t *testing.T) {
if dummyHash == nil {
t.Fatal("dummyHash should not be nil")
}
if len(dummyHash) == 0 {
t.Fatal("dummyHash should not be empty")
}
// It should be a valid bcrypt hash (starts with $2a$ or $2b$)
s := string(dummyHash)
if !strings.HasPrefix(s, "$2a$") && !strings.HasPrefix(s, "$2b$") {
t.Fatalf("dummyHash does not look like a bcrypt hash: %s", s)
}
}
// ---------- Password character class helpers ----------
func TestPasswordCharacterClasses(t *testing.T) {
tests := []struct {
password string
hasUpper bool
hasLower bool
hasDigit bool
hasSpecial bool
}{
{"abc", false, true, false, false},
{"ABC", true, false, false, false},
{"123", false, false, true, false},
{"!@#", false, false, false, true},
{"aB1!", true, true, true, true},
{"", false, false, false, false},
}
for _, tt := range tests {
var upper, lower, digit, special bool
for _, r := range tt.password {
if unicode.IsUpper(r) {
upper = true
}
if unicode.IsLower(r) {
lower = true
}
if unicode.IsDigit(r) {
digit = true
}
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
special = true
}
}
if upper != tt.hasUpper || lower != tt.hasLower || digit != tt.hasDigit || special != tt.hasSpecial {
t.Errorf("password %q: got upper=%v lower=%v digit=%v special=%v, want upper=%v lower=%v digit=%v special=%v",
tt.password, upper, lower, digit, special, tt.hasUpper, tt.hasLower, tt.hasDigit, tt.hasSpecial)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration
@@ -30,6 +31,9 @@ type Config struct {
SMTPTLS bool
SMTPEnabled bool
// Timezone
Timezone *time.Location // parsed from TZ env var; defaults to UTC
// Security / Hardening
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
@@ -55,6 +59,13 @@ func Load() *Config {
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
// Load timezone from TZ environment variable (standard for Docker)
tzName := getEnv("TZ", "UTC")
tz, err := time.LoadLocation(tzName)
if err != nil {
tz = time.UTC
}
return &Config{
Port: getEnv("KEYWARDEN_PORT", "8080"),
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
@@ -73,6 +84,8 @@ func Load() *Config {
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
SMTPEnabled: smtpHost != "",
Timezone: tz,
BaseURL: baseURL,
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
SecureCookies: secureCookies,

View File

@@ -48,7 +48,7 @@ func (d *DB) ExportAll() (*BackupData, error) {
{`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at FROM servers ORDER BY id`, &backup.Servers},
{`SELECT id, user_id, name, description, created_at, updated_at FROM server_groups ORDER BY id`, &backup.ServerGroups},
{`SELECT id, group_id, server_id FROM server_group_members ORDER BY id`, &backup.GroupMembers},
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message, key_name FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
{`SELECT id, user_id, action, details, ip_address, created_at FROM audit_log ORDER BY id`, &backup.AuditLog},
{`SELECT key, value, updated_at FROM settings ORDER BY key`, &backup.Settings},
{`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at FROM access_assignments ORDER BY id`, &backup.AccessAssign},
@@ -115,7 +115,7 @@ func (d *DB) ImportAll(backup *BackupData) error {
{"servers", []string{"id", "user_id", "name", "hostname", "port", "username", "description", "created_at", "updated_at"}, backup.Servers},
{"server_groups", []string{"id", "user_id", "name", "description", "created_at", "updated_at"}, backup.ServerGroups},
{"server_group_members", []string{"id", "group_id", "server_id"}, backup.GroupMembers},
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message"}, backup.KeyDeployments},
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message", "key_name"}, backup.KeyDeployments},
{"audit_log", []string{"id", "user_id", "action", "details", "ip_address", "created_at"}, backup.AuditLog},
{"settings", []string{"key", "value", "updated_at"}, backup.Settings},
{"access_assignments", []string{"id", "user_id", "ssh_key_id", "server_id", "group_id", "system_user", "desired_state", "sudo", "create_user", "initial_password", "status", "last_sync_at", "created_at", "updated_at"}, backup.AccessAssign},
@@ -145,7 +145,12 @@ func (d *DB) ImportAll(backup *BackupData) error {
for _, row := range imp.data {
args := make([]interface{}, len(imp.columns))
for i, col := range imp.columns {
args[i] = row[col]
v := row[col]
// For columns with NOT NULL DEFAULT '', treat nil as empty string
if v == nil && col == "key_name" {
v = ""
}
args[i] = v
}
if _, err := stmt.Exec(args...); err != nil {
stmt.Close()

View File

@@ -5,6 +5,7 @@
package database
import (
"context"
"database/sql"
"fmt"
"os"
@@ -266,5 +267,40 @@ func (d *DB) migrate() error {
}
}
// Migration: recreate key_deployments with nullable ssh_key_id and key_name column
// This is needed so the system master key (which has no ssh_keys row) can be logged.
{
var migCount int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'key_deployments_nullable_keyid'`).Scan(&migCount)
if migCount == 0 {
ctx := context.Background()
conn, err := d.DB.Conn(ctx)
if err == nil {
conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`)
conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS key_deployments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ssh_key_id INTEGER,
server_id INTEGER NOT NULL,
deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT,
key_name TEXT NOT NULL DEFAULT '',
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
)`)
conn.ExecContext(ctx, `INSERT INTO key_deployments_new (id, ssh_key_id, server_id, deployed_at, status, message, key_name)
SELECT kd.id, kd.ssh_key_id, kd.server_id, kd.deployed_at, kd.status, kd.message,
COALESCE(sk.name, '')
FROM key_deployments kd
LEFT JOIN ssh_keys sk ON kd.ssh_key_id = sk.id`)
conn.ExecContext(ctx, `DROP TABLE key_deployments`)
conn.ExecContext(ctx, `ALTER TABLE key_deployments_new RENAME TO key_deployments`)
conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
conn.Close()
}
d.Exec(`INSERT INTO _migrations (name) VALUES ('key_deployments_nullable_keyid')`)
}
}
return nil
}

View File

@@ -615,20 +615,32 @@ func (s *Service) TestSSHAuth(hostname string, port int, username string, privat
// logDeployment records a deployment attempt
func (s *Service) logDeployment(keyID, serverID int64, status, message string) {
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message) VALUES (?, ?, ?, ?)`,
keyID, serverID, status, message,
)
if keyID <= 0 {
// Master key or virtual key: store with NULL ssh_key_id
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message, key_name) VALUES (NULL, ?, ?, ?, '[MASTER] System Master Key')`,
serverID, status, message,
)
} else {
var keyName string
s.db.QueryRow(`SELECT name FROM ssh_keys WHERE id = ?`, keyID).Scan(&keyName)
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message, key_name) VALUES (?, ?, ?, ?, ?)`,
keyID, serverID, status, message, keyName,
)
}
}
// GetDeployments returns deployment history for a user's keys
func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error) {
rows, err := s.db.Query(
`SELECT kd.id, sk.name as key_name, srv.name as server_name, kd.status, kd.message, kd.deployed_at
`SELECT kd.id,
CASE WHEN kd.key_name != '' THEN kd.key_name ELSE COALESCE(sk.name, 'Unknown') END as key_name,
srv.name as server_name, kd.status, kd.message, kd.deployed_at
FROM key_deployments kd
JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
LEFT JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
JOIN servers srv ON kd.server_id = srv.id
WHERE sk.user_id = ?
WHERE sk.user_id = ? OR kd.ssh_key_id IS NULL
ORDER BY kd.deployed_at DESC LIMIT 50`, userID,
)
if err != nil {

View File

@@ -19,9 +19,12 @@ type Service struct {
key []byte // 32 bytes for AES-256
}
// NewService creates a new encryption service from a passphrase
// NewService creates a new encryption service from a passphrase.
//
// NOTE: Key derivation currently uses SHA-256 for backward compatibility with
// existing encrypted data. A migration to a proper KDF (e.g. Argon2id) would
// require re-encrypting all stored secrets and is tracked as a future improvement.
func NewService(passphrase string) *Service {
// Derive a 32-byte key from the passphrase using SHA-256
hash := sha256.Sum256([]byte(passphrase))
return &Service{key: hash[:]}
}

View File

@@ -5,6 +5,7 @@
package handlers
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
@@ -15,6 +16,9 @@ import (
"encoding/json"
"fmt"
"html/template"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"io/fs"
"math"
@@ -28,6 +32,8 @@ import (
"sync"
"time"
_ "golang.org/x/image/webp"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
"git.techniverse.net/scriptos/keywarden/internal/cron"
@@ -194,6 +200,7 @@ type SystemInfo struct {
Runtime string // e.g. "Docker" or "Native"
Hostname string
Uptime string
Timezone string
}
// AdminUserInfo holds user info for the admin settings page
@@ -266,6 +273,12 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
logging.Warn("Failed to create avatars directory %s: %v", avatarsDir, err)
}
// Ensure branding directory exists
brandingDir := filepath.Join(dataDir, "branding")
if err := os.MkdirAll(brandingDir, 0700); err != nil {
logging.Warn("Failed to create branding directory %s: %v", brandingDir, err)
}
h := &Handler{
auth: authSvc,
keys: keysSvc,
@@ -317,6 +330,59 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
"releaseURL": func() string {
return h.updater.ReleaseURL()
},
"releasesPageURL": func() string {
return updater.ReleasesPageURL
},
"loginBgImage": func() string {
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
if _, err := os.Stat(bgPath); err == nil {
return "/branding/login-bg"
}
return ""
},
"loginTextColor": func() string {
c, _ := h.auth.GetSetting("login_text_color")
if c == "" {
return "light"
}
return c
},
// formatTime converts a time.Time to the app timezone and formats as "2006-01-02 15:04"
"formatTime": func(v interface{}) string {
switch t := v.(type) {
case time.Time:
return t.Local().Format("2006-01-02 15:04")
case *time.Time:
if t != nil {
return t.Local().Format("2006-01-02 15:04")
}
}
return ""
},
// formatDateTime converts a time.Time to the app timezone and formats as "2006-01-02 15:04:05"
"formatDateTime": func(v interface{}) string {
switch t := v.(type) {
case time.Time:
return t.Local().Format("2006-01-02 15:04:05")
case *time.Time:
if t != nil {
return t.Local().Format("2006-01-02 15:04:05")
}
}
return ""
},
// formatDateTimeLocal converts a time.Time to the app timezone and formats for HTML datetime-local inputs
"formatDateTimeLocal": func(v interface{}) string {
switch t := v.(type) {
case time.Time:
return t.Local().Format("2006-01-02T15:04")
case *time.Time:
if t != nil {
return t.Local().Format("2006-01-02T15:04")
}
}
return ""
},
}
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
@@ -399,6 +465,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/static/", h.handleStatic)
// Public routes
mux.HandleFunc("/branding/login-bg", h.handleLoginBgServe)
mux.HandleFunc("/login", h.handleLogin)
mux.HandleFunc("/login/mfa", h.handleLoginMFA)
mux.HandleFunc("/logout", h.handleLogout)
@@ -454,6 +521,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Owner-only routes
mux.HandleFunc("/admin/settings", h.requireOwner(h.handleAdminSettings))
mux.HandleFunc("/admin/branding/upload", h.requireOwner(h.handleLoginBrandingUpload))
mux.HandleFunc("/admin/branding/remove-bg", h.requireOwner(h.handleLoginBrandingRemoveBg))
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))
@@ -567,11 +636,13 @@ func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
h.mu.Unlock()
logging.Info("Session expired for user ID %d due to inactivity (%v timeout)", sess.UserID, timeout)
http.SetCookie(w, &http.Cookie{
Name: "keywarden_session",
Value: "",
Path: "/",
Secure: h.secureCookies,
MaxAge: -1,
Name: "keywarden_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: h.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
@@ -856,11 +927,13 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &http.Cookie{
Name: "keywarden_session",
Value: "",
Path: "/",
Secure: h.secureCookies,
MaxAge: -1,
Name: "keywarden_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: h.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -1430,6 +1503,36 @@ func (h *Handler) handleServerTestAuth(w http.ResponseWriter, r *http.Request) {
}
}
// masterKeyForDeploy returns the system master key as a virtual SSHKey entry for deployment.
// Returns nil if the master key is not available.
func (h *Handler) masterKeyForDeploy() *models.SSHKey {
pub, err := h.keys.GetSystemMasterKeyPublic()
if err != nil || pub == "" {
return nil
}
fp, _ := h.keys.GetSystemMasterKeyFingerprint()
return &models.SSHKey{
ID: -1,
UserID: 0,
Name: "[MASTER] System Master Key",
KeyType: "ed25519",
PublicKey: pub,
Fingerprint: fp,
}
}
// prependMasterKey adds the system master key to the key list if the user is an owner.
func (h *Handler) prependMasterKey(keyList []models.SSHKey, role string) []models.SSHKey {
if !isOwner(role) {
return keyList
}
mk := h.masterKeyForDeploy()
if mk == nil {
return keyList
}
return append([]models.SSHKey{*mk}, keyList...)
}
func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request) {
userID := h.getUserID(r)
user, _ := h.auth.GetUserByID(userID)
@@ -1438,6 +1541,9 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request) {
groups, _ := h.servers.GetAllGroups()
deployments, _ := h.deploy.GetDeployments(userID)
// Owner can deploy the system master key
keyList = h.prependMasterKey(keyList, user.Role)
if r.Method == http.MethodGet {
data := &PageData{
Title: "Deploy Keys",
@@ -1457,14 +1563,26 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request) {
serverID, _ := strconv.ParseInt(r.FormValue("server_id"), 10, 64)
authMethod := r.FormValue("auth_method")
key, err := h.keys.GetKeyByID(keyID, userID)
if err != nil {
// Try global access for admin/owner deploying other users' keys
key, err = h.keys.GetKeyByIDGlobal(keyID)
if err != nil {
var key *models.SSHKey
var err error
if keyID == -1 && isOwner(user.Role) {
// Owner deploying the system master key
key = h.masterKeyForDeploy()
if key == nil {
http.Redirect(w, r, "/deploy", http.StatusSeeOther)
return
}
} else {
key, err = h.keys.GetKeyByID(keyID, userID)
if err != nil {
// Try global access for admin/owner deploying other users' keys
key, err = h.keys.GetKeyByIDGlobal(keyID)
if err != nil {
http.Redirect(w, r, "/deploy", http.StatusSeeOther)
return
}
}
}
server, err := h.servers.GetByIDGlobal(serverID)
@@ -1508,7 +1626,18 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request) {
logging.Info("Deploy successful: key='%s' target=%s@%s:%d", key.Name, server.Username, server.Hostname, server.Port)
h.audit.Log(userID, audit.ActionDeploySuccess, fmt.Sprintf("Deployed key '%s' to %s@%s:%d", key.Name, server.Username, server.Hostname, server.Port), clientIP(r))
http.Redirect(w, r, "/deploy", http.StatusSeeOther)
deployments, _ = h.deploy.GetDeployments(userID)
data := &PageData{
Title: "Deploy Keys",
Active: "deploy",
User: user,
Keys: keyList,
Servers: serverList,
Groups: groups,
Deployments: deployments,
Flash: &Flash{Type: "success", Message: fmt.Sprintf("Key '%s' successfully deployed to %s@%s:%d.", key.Name, server.Username, server.Hostname, server.Port)},
}
h.templates["deploy"].ExecuteTemplate(w, "base", data)
}
func (h *Handler) handleDeployGroup(w http.ResponseWriter, r *http.Request) {
@@ -1523,14 +1652,26 @@ func (h *Handler) handleDeployGroup(w http.ResponseWriter, r *http.Request) {
groupID, _ := strconv.ParseInt(r.FormValue("group_id"), 10, 64)
authMethod := r.FormValue("auth_method")
key, err := h.keys.GetKeyByID(keyID, userID)
if err != nil {
// Try global access for admin/owner deploying other users' keys
key, err = h.keys.GetKeyByIDGlobal(keyID)
if err != nil {
var key *models.SSHKey
var keyErr error
if keyID == -1 && isOwner(user.Role) {
// Owner deploying the system master key
key = h.masterKeyForDeploy()
if key == nil {
http.Redirect(w, r, "/deploy", http.StatusSeeOther)
return
}
} else {
key, keyErr = h.keys.GetKeyByID(keyID, userID)
if keyErr != nil {
// Try global access for admin/owner deploying other users' keys
key, keyErr = h.keys.GetKeyByIDGlobal(keyID)
if keyErr != nil {
http.Redirect(w, r, "/deploy", http.StatusSeeOther)
return
}
}
}
group, err := h.servers.GetGroupByIDGlobal(groupID)
@@ -1542,6 +1683,7 @@ func (h *Handler) handleDeployGroup(w http.ResponseWriter, r *http.Request) {
members, err := h.servers.GetGroupMembersGlobal(groupID)
if err != nil || len(members) == 0 {
keyList, _ := h.keys.GetAllKeys()
keyList = h.prependMasterKey(keyList, user.Role)
serverList, _ := h.servers.GetAllServers()
groups, _ := h.servers.GetAllGroups()
deployments, _ := h.deploy.GetDeployments(userID)
@@ -1597,6 +1739,7 @@ func (h *Handler) handleDeployGroup(w http.ResponseWriter, r *http.Request) {
}
keyList, _ := h.keys.GetAllKeys()
keyList = h.prependMasterKey(keyList, user.Role)
serverList, _ := h.servers.GetAllServers()
groups, _ := h.servers.GetAllGroups()
deployments, _ := h.deploy.GetDeployments(userID)
@@ -2480,9 +2623,9 @@ func (h *Handler) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Limit upload to 2MB
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
if err := r.ParseMultipartForm(2 << 20); err != nil {
// Limit upload to 5MB
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(5 << 20); err != nil {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
@@ -3016,6 +3159,7 @@ func (h *Handler) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
Runtime: runtimeEnv,
Hostname: hostname,
Uptime: uptimeStr,
Timezone: time.Local.String(),
}
data := &PageData{
@@ -3027,6 +3171,146 @@ func (h *Handler) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
h.templates["system_info"].ExecuteTemplate(w, "base", data)
}
// handleLoginBrandingUpload handles background image upload for the login page
func (h *Handler) handleLoginBrandingUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
return
}
userID := h.getUserID(r)
// Limit upload to 5MB
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(5 << 20); err != nil {
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("File too large. Maximum size is 5 MB."), http.StatusSeeOther)
return
}
file, header, err := r.FormFile("login_bg")
if err != nil {
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("No file selected."), http.StatusSeeOther)
return
}
defer file.Close()
// Validate content type
ct := header.Header.Get("Content-Type")
if ct != "image/png" && ct != "image/jpeg" && ct != "image/webp" {
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Invalid file type. Only PNG, JPEG and WebP are allowed."), http.StatusSeeOther)
return
}
data, err := io.ReadAll(file)
if err != nil {
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to read uploaded file."), http.StatusSeeOther)
return
}
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
if err := os.WriteFile(bgPath, data, 0600); err != nil {
logging.Warn("Failed to save login background image: %v", err)
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to save background image."), http.StatusSeeOther)
return
}
// Auto-detect brightness and set text color accordingly
textColor := analyzeImageBrightness(data)
if err := h.auth.SetSetting("login_text_color", textColor); err != nil {
logging.Warn("Failed to save auto-detected text color: %v", err)
}
logging.Info("Login background uploaded: auto-detected text color = %s", textColor)
h.audit.Log(userID, audit.ActionBrandingChanged, fmt.Sprintf("Login background image uploaded (auto text color: %s)", textColor), clientIP(r))
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image uploaded successfully."), http.StatusSeeOther)
}
// handleLoginBrandingRemoveBg removes the login page background image
func (h *Handler) handleLoginBrandingRemoveBg(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
return
}
userID := h.getUserID(r)
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
os.Remove(bgPath)
// Reset auto-detected text color
_ = h.auth.SetSetting("login_text_color", "light")
h.audit.Log(userID, audit.ActionBrandingChanged, "Login background image removed", clientIP(r))
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Background image removed."), http.StatusSeeOther)
}
// handleLoginBgServe serves the login page background image (public, no auth required)
func (h *Handler) handleLoginBgServe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bgPath := filepath.Join(h.dataDir, "branding", "login_bg")
data, err := os.ReadFile(bgPath)
if err != nil {
http.NotFound(w, r)
return
}
contentType := http.DetectContentType(data)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
// analyzeImageBrightness decodes an image and computes the average perceived
// brightness using the ITU-R BT.709 luminance formula. It samples every Nth
// pixel for performance. Returns "light" if the image is dark (bright text
// needed) or "dark" if the image is bright (dark text needed).
func analyzeImageBrightness(data []byte) string {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
// Cannot decode → assume dark image, use light text
return "light"
}
bounds := img.Bounds()
width := bounds.Max.X - bounds.Min.X
height := bounds.Max.Y - bounds.Min.Y
totalPixels := width * height
// Sample step: aim for ~10 000 pixels max for performance
step := 1
if totalPixels > 10000 {
step = int(math.Sqrt(float64(totalPixels) / 10000))
if step < 1 {
step = 1
}
}
var sum float64
var count int
for y := bounds.Min.Y; y < bounds.Max.Y; y += step {
for x := bounds.Min.X; x < bounds.Max.X; x += step {
r, g, b, _ := img.At(x, y).RGBA()
// ITU-R BT.709 perceived luminance (values are 065535)
lum := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
sum += lum
count++
}
}
if count == 0 {
return "light"
}
avg := sum / float64(count)
// 65535 / 2 = 32767.5 → threshold at ~40% brightness
if avg < 26214 {
return "light" // dark image → use light/white text
}
return "dark" // bright image → use dark text
}
func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
userID := h.getUserID(r)
user, _ := h.auth.GetUserByID(userID)

View File

@@ -8,11 +8,12 @@ import (
"bytes"
"crypto/tls"
"fmt"
htmltpl "html/template"
"mime"
"net"
"net/smtp"
"strings"
"text/template"
texttpl "text/template"
"time"
"git.techniverse.net/scriptos/keywarden/internal/config"
@@ -66,7 +67,7 @@ func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationDa
return nil
}
htmlBody, err := renderTemplate(loginNotificationHTML, data)
htmlBody, err := renderHTMLTemplate(loginNotificationHTML, data)
if err != nil {
return fmt.Errorf("failed to render HTML template: %w", err)
}
@@ -108,7 +109,7 @@ func (s *Service) SendInvitation(toEmail string, data InvitationData) error {
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
}
htmlBody, err := renderTemplate(invitationHTML, data)
htmlBody, err := renderHTMLTemplate(invitationHTML, data)
if err != nil {
return fmt.Errorf("failed to render invitation HTML template: %w", err)
}
@@ -317,7 +318,21 @@ func (s *Service) send(to string, msg []byte) error {
}
func renderTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := template.New("email").Parse(tmplStr)
tmpl, err := texttpl.New("email").Parse(tmplStr)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// renderHTMLTemplate uses html/template for proper context-aware escaping,
// preventing XSS in HTML email bodies when user-supplied data is included.
func renderHTMLTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := htmltpl.New("email").Parse(tmplStr)
if err != nil {
return "", err
}

112
internal/security/gzip.go Normal file
View File

@@ -0,0 +1,112 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
// compressibleTypes lists MIME types that benefit from gzip compression.
// Binary formats like woff2, images, etc. are already compressed.
var compressibleTypes = map[string]bool{
"text/html": true,
"text/css": true,
"text/plain": true,
"text/javascript": true,
"application/javascript": true,
"application/json": true,
"application/xml": true,
"image/svg+xml": true,
}
var gzipWriterPool = sync.Pool{
New: func() interface{} {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
return w
},
}
// GzipMiddleware compresses HTTP responses for clients that accept gzip.
// Only compressible content types (text, CSS, JS, JSON, SVG) are compressed;
// already-compressed formats (woff2, images) are passed through unchanged.
func GzipMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gz := gzipWriterPool.Get().(*gzip.Writer)
gz.Reset(w)
grw := &gzipResponseWriter{
ResponseWriter: w,
gz: gz,
}
next.ServeHTTP(grw, r)
if grw.compressed {
gz.Close()
}
gzipWriterPool.Put(gz)
})
}
}
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
compressed bool
decided bool
}
func (w *gzipResponseWriter) WriteHeader(code int) {
if w.decided {
w.ResponseWriter.WriteHeader(code)
return
}
w.decided = true
// Only compress successful full responses (not 304, 206, redirects, errors)
if code == http.StatusOK {
ct := w.Header().Get("Content-Type")
if idx := strings.Index(ct, ";"); idx >= 0 {
ct = strings.TrimSpace(ct[:idx])
}
if compressibleTypes[ct] {
w.compressed = true
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
}
}
w.ResponseWriter.WriteHeader(code)
}
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
if !w.decided {
w.WriteHeader(http.StatusOK)
}
if w.compressed {
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
// Flush implements http.Flusher for streaming responses.
func (w *gzipResponseWriter) Flush() {
if w.compressed {
w.gz.Flush()
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}

View File

@@ -0,0 +1,350 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---------- CSRF Middleware ----------
func TestCSRFMiddleware_SetsTokenCookie(t *testing.T) {
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cookies := rec.Result().Cookies()
var csrfCookie *http.Cookie
for _, c := range cookies {
if c.Name == "_csrf" {
csrfCookie = c
}
}
if csrfCookie == nil {
t.Fatal("expected _csrf cookie to be set on GET request")
}
if len(csrfCookie.Value) != 64 {
t.Fatalf("expected 64-char hex token, got %d chars", len(csrfCookie.Value))
}
if csrfCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("expected SameSite=Strict on CSRF cookie")
}
}
func TestCSRFMiddleware_BlocksPOSTWithoutToken(t *testing.T) {
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/action", nil)
req.AddCookie(&http.Cookie{Name: "_csrf", Value: strings.Repeat("a", 64)})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden for POST without matching token, got %d", rec.Code)
}
}
func TestCSRFMiddleware_AllowsPOSTWithValidToken(t *testing.T) {
token := strings.Repeat("ab", 32) // 64-char hex
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
body := strings.NewReader("_csrf=" + token)
req := httptest.NewRequest(http.MethodPost, "/action", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "_csrf", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for POST with valid CSRF token, got %d", rec.Code)
}
}
func TestCSRFMiddleware_AllowsGETWithoutToken(t *testing.T) {
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/page", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET without CSRF token, got %d", rec.Code)
}
}
func TestCSRFMiddleware_AcceptsHeaderToken(t *testing.T) {
token := strings.Repeat("cd", 32) // 64-char hex
handler := CSRFMiddleware(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/action", nil)
req.Header.Set("X-CSRF-Token", token)
req.AddCookie(&http.Cookie{Name: "_csrf", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for POST with X-CSRF-Token header, got %d", rec.Code)
}
}
// ---------- Security Headers Middleware ----------
func TestHeadersMiddleware_SetsSecurityHeaders(t *testing.T) {
handler := HeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
expected := map[string]string{
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
for header, want := range expected {
got := rec.Header().Get(header)
if got != want {
t.Errorf("header %s: got %q, want %q", header, got, want)
}
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" {
t.Fatal("expected Content-Security-Policy header to be set")
}
if !strings.Contains(csp, "frame-ancestors 'none'") {
t.Error("CSP should contain frame-ancestors 'none'")
}
if !strings.Contains(csp, "form-action 'self'") {
t.Error("CSP should contain form-action 'self'")
}
}
func TestHeadersMiddleware_SetsCacheControlForNonStatic(t *testing.T) {
handler := HeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cc := rec.Header().Get("Cache-Control")
if !strings.Contains(cc, "no-store") {
t.Errorf("expected no-store in Cache-Control for non-static page, got %q", cc)
}
}
// ---------- Rate Limit Middleware ----------
func TestRateLimitMiddleware_BlocksAfterLimit(t *testing.T) {
handler := RateLimitMiddleware(3)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 3; i++ {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, rec.Code)
}
}
// 4th request should be blocked
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 Too Many Requests, got %d", rec.Code)
}
}
func TestRateLimitMiddleware_DisabledWhenZero(t *testing.T) {
handler := RateLimitMiddleware(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 100; i++ {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected 200 (rate limiting disabled), got %d", i+1, rec.Code)
}
}
}
func TestRateLimitMiddleware_AllowsGETLogin(t *testing.T) {
handler := RateLimitMiddleware(1)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust POST limit
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// GET should still work
req = httptest.NewRequest(http.MethodGet, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected GET /login to pass rate limit, got %d", rec.Code)
}
}
func TestRateLimitMiddleware_SeparatesIPs(t *testing.T) {
handler := RateLimitMiddleware(1)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust limit for IP 1
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// IP 2 should still be allowed
req = httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.0.2.2:12345"
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected different IP to be allowed, got %d", rec.Code)
}
}
// ---------- Size Limit Middleware ----------
func TestSizeLimitMiddleware_BlocksOversizedBody(t *testing.T) {
handler := SizeLimitMiddleware(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1024)
_, err := r.Body.Read(buf)
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}))
body := strings.NewReader(strings.Repeat("x", 100))
req := httptest.NewRequest(http.MethodPost, "/upload", body)
req.Header.Set("Content-Type", "application/octet-stream")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("expected request with body > 10 bytes to be rejected")
}
}
func TestSizeLimitMiddleware_DisabledWhenZero(t *testing.T) {
handler := SizeLimitMiddleware(0)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
body := strings.NewReader(strings.Repeat("x", 1000))
req := httptest.NewRequest(http.MethodPost, "/upload", body)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 with size limit disabled, got %d", rec.Code)
}
}
// ---------- Proxy / ClientIP ----------
func TestClientIP_RemoteAddrFallback(t *testing.T) {
Init("") // no trusted proxies
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
ip := ClientIP(req)
if ip != "10.0.0.1" {
t.Fatalf("expected 10.0.0.1, got %s", ip)
}
}
func TestClientIP_XForwardedFor_Legacy(t *testing.T) {
Init("") // legacy mode, trusts headers
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.1")
ip := ClientIP(req)
if ip != "203.0.113.50" {
t.Fatalf("expected leftmost XFF IP 203.0.113.50, got %s", ip)
}
}
func TestClientIP_TrustedProxies(t *testing.T) {
Init("10.0.0.0/8")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2")
ip := ClientIP(req)
if ip != "203.0.113.50" {
t.Fatalf("expected rightmost untrusted IP 203.0.113.50, got %s", ip)
}
}
func TestClientIP_UntrustedPeerIgnoresHeaders(t *testing.T) {
Init("10.0.0.0/8")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.99:54321" // not in trusted range
req.Header.Set("X-Forwarded-For", "1.2.3.4")
ip := ClientIP(req)
if ip != "203.0.113.99" {
t.Fatalf("expected direct peer IP when not trusted, got %s", ip)
}
}
// ---------- isStaticAsset ----------
func TestIsStaticAsset(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"/static/css/style.css", true},
{"/avatar/1.png", true},
{"/dashboard", false},
{"/login", false},
{"", false},
{"/short", false},
}
for _, tt := range tests {
got := isStaticAsset(tt.path)
if got != tt.want {
t.Errorf("isStaticAsset(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}

View File

@@ -18,6 +18,8 @@ import (
const (
// Gitea API endpoint for releases
releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5"
// Public releases page URL
ReleasesPageURL = "https://git.techniverse.net/scriptos/keywarden/releases"
// How often to check for updates
checkInterval = 6 * time.Hour
// HTTP timeout for API requests
@@ -155,6 +157,8 @@ func (s *Service) check() {
// isNewer returns true if latest is a higher version than current.
// Both may optionally have a "v" prefix (e.g. "v1.2.3").
// Pre-release versions (e.g. "v1.0.0-alpha") are considered older than the
// same version without a suffix (e.g. "v1.0.0").
func isNewer(latest, current string) bool {
latestParts := parseVersion(latest)
currentParts := parseVersion(current)
@@ -174,12 +178,25 @@ func isNewer(latest, current string) bool {
return false
}
}
// Numeric parts are equal a stable release is newer than a pre-release
// of the same version (e.g. v0.3.0 > v0.3.0-alpha).
if hasPreRelease(current) && !hasPreRelease(latest) {
return true
}
return false
}
// parseVersion strips the "v" prefix and splits "1.2.3" into [1, 2, 3].
// parseVersion strips the "v" prefix and pre-release suffixes (e.g. "-alpha")
// then splits "1.2.3" into [1, 2, 3].
// A pre-release version is indicated by returning isPreRelease = true.
func parseVersion(v string) []int {
v = strings.TrimPrefix(v, "v")
// Strip pre-release suffix (e.g. "-alpha", "-beta.1", "-rc1")
if idx := strings.IndexByte(v, '-'); idx >= 0 {
v = v[:idx]
}
parts := strings.Split(v, ".")
nums := make([]int, 0, len(parts))
for _, p := range parts {
@@ -191,3 +208,10 @@ func parseVersion(v string) []int {
}
return nums
}
// hasPreRelease returns true when the version string contains a pre-release
// suffix (e.g. "-alpha", "-beta", "-rc1").
func hasPreRelease(v string) bool {
v = strings.TrimPrefix(v, "v")
return strings.ContainsRune(v, '-')
}

View File

@@ -0,0 +1,14 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package version
// Version is the current application version.
// This is the SINGLE SOURCE OF TRUTH for the version number.
// Update this value for each release.
//
// It can still be overridden at build time via:
//
// go build -ldflags "-X git.techniverse.net/scriptos/keywarden/internal/version.Version=v1.0.0"
var Version = "v0.4.0-alpha"

181
tools/subset-icons.py Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Tabler Icons Subset Tool for Keywarden
=======================================
Scans all HTML templates for used ti-* icon classes, then generates:
1. A subsetted woff2 font with only the needed glyphs
2. A minimal CSS file with only the matching icon rules
Prerequisites (one-time):
pip install fonttools brotli
Usage:
python tools/subset-icons.py
Source files (full Tabler Icons 3.6.0) are stored in tools/tabler-icons-full/.
Output goes directly to web/static/css/ and web/static/css/fonts/.
"""
import os
import re
import subprocess
import sys
# ── Paths (relative to project root) ──
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "web", "templates")
FULL_CSS = os.path.join(SCRIPT_DIR, "tabler-icons-full", "tabler-icons.min.css")
FULL_FONT = os.path.join(SCRIPT_DIR, "tabler-icons-full", "tabler-icons.woff2")
OUT_CSS = os.path.join(PROJECT_ROOT, "web", "static", "css", "tabler-icons.min.css")
OUT_FONT = os.path.join(PROJECT_ROOT, "web", "static", "css", "fonts", "tabler-icons.woff2")
def find_used_icons():
"""Scan all .html templates for ti-* class names."""
icons = set()
pattern = re.compile(r"ti-[a-z][a-z0-9-]+")
for root, _, files in os.walk(TEMPLATE_DIR):
for f in files:
if not f.endswith(".html"):
continue
with open(os.path.join(root, f), encoding="utf-8") as fh:
for match in pattern.finditer(fh.read()):
icons.add(match.group(0))
# ti-spin is a CSS animation class, not an icon glyph
icons.discard("ti-spin")
return sorted(icons)
def extract_codepoints(css_text, icons):
"""Extract Unicode codepoints from the full CSS for each icon."""
codepoints = []
missing = []
for icon in icons:
pat = re.escape("." + icon) + r':before\{content:"\\([0-9a-f]+)"\}'
m = re.search(pat, css_text)
if m:
codepoints.append(m.group(1))
else:
missing.append(icon)
return codepoints, missing
def build_subset_css(css_text, icons):
"""Build a minimal CSS containing only the @font-face, .ti base rule,
and the individual icon rules for the used icons."""
# Extract header comment + @font-face + .ti base rule
m = re.match(r'(/\*[\s\S]*?\*/)(@font-face\{[^}]+\})(\.ti\{[^}]+\})', css_text)
if not m:
print("ERROR: Could not parse base CSS rules from full source")
sys.exit(1)
# Patch @font-face: keep only woff2 and add font-display:swap
font_face = m.group(2)
# Remove woff and truetype sources, keep only woff2
font_face = re.sub(
r',url\("[^"]*\.woff\?[^"]*"\)\s*format\("woff"\)', '', font_face
)
font_face = re.sub(
r',url\("[^"]*\.ttf[^"]*"\)\s*format\("truetype"\)', '', font_face
)
# Add font-display:swap if not present
if "font-display" not in font_face:
font_face = font_face.replace(
"font-weight:400;",
"font-weight:400;font-display:swap;"
)
header = m.group(1) + font_face + m.group(3)
# Extract individual icon rules
rules = []
for icon in icons:
pat = re.escape("." + icon) + r':before\{content:"\\[0-9a-f]+"\}'
match = re.search(pat, css_text)
if match:
rules.append(match.group(0))
# Keep .ti-spin animation if present
result = header + "".join(rules)
spin_kf = re.search(r'@keyframes\s+spin\{[^}]+\{[^}]+\}\}', css_text)
spin_cls = re.search(r'\.ti-spin\{[^}]+\}', css_text)
if spin_kf:
result += spin_kf.group(0)
if spin_cls:
result += spin_cls.group(0)
return result, len(rules)
def subset_font(codepoints):
"""Run pyftsubset to create a woff2 with only the needed glyphs."""
unicodes = ",".join(f"U+{cp}" for cp in codepoints)
cmd = [
sys.executable, "-m", "fontTools.subset",
FULL_FONT,
f"--output-file={OUT_FONT}",
"--flavor=woff2",
"--no-layout-closure",
"--drop-tables+=GSUB,GPOS,GDEF",
f"--unicodes={unicodes}",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print("ERROR: pyftsubset failed:")
print(result.stderr)
sys.exit(1)
def main():
# Verify source files exist
for path in (FULL_CSS, FULL_FONT):
if not os.path.exists(path):
print(f"ERROR: Source file missing: {path}")
print("These should be in tools/tabler-icons-full/")
sys.exit(1)
# Try importing fonttools
try:
import fontTools # noqa: F401
except ImportError:
print("ERROR: fonttools not installed. Run: pip install fonttools brotli")
sys.exit(1)
print("Scanning templates for icon usage...")
icons = find_used_icons()
print(f" Found {len(icons)} unique icons")
print("Reading full CSS source...")
with open(FULL_CSS, encoding="utf-8") as f:
full_css = f.read()
print("Extracting Unicode codepoints...")
codepoints, missing = extract_codepoints(full_css, icons)
if missing:
print(f" WARNING: No codepoint found for: {', '.join(missing)}")
print(f" Mapped {len(codepoints)} codepoints")
print("Subsetting font...")
subset_font(codepoints)
orig_size = os.path.getsize(FULL_FONT)
new_size = os.path.getsize(OUT_FONT)
print(f" {orig_size//1024} KB -> {new_size//1024} KB ({100-round(new_size/orig_size*100,1)}% smaller)")
print("Building subset CSS...")
css_out, rule_count = build_subset_css(full_css, icons)
with open(OUT_CSS, "w", encoding="utf-8") as f:
f.write(css_out)
orig_css_size = os.path.getsize(FULL_CSS)
new_css_size = os.path.getsize(OUT_CSS)
print(f" {rule_count} icon rules, {orig_css_size//1024} KB -> {new_css_size//1024} KB")
print("\nDone! Subsetted files written to:")
print(f" Font: {os.path.relpath(OUT_FONT, PROJECT_ROOT)}")
print(f" CSS: {os.path.relpath(OUT_CSS, PROJECT_ROOT)}")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,49 @@
{{define "content"}}
<div class="row row-deck row-cards">
<!-- Login Page Customization -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-palette"></i> Login Page Customization</h3>
</div>
<div class="card-body">
<!-- Background Image -->
<h4 class="mb-3"><i class="ti ti-photo"></i> Background Image</h4>
{{if loginBgImage}}
<div class="mb-3">
<div class="row align-items-center">
<div class="col-auto">
<img src="{{loginBgImage}}" class="rounded border" style="max-width: 240px; max-height: 140px; object-fit: cover;">
</div>
<div class="col-auto">
<form action="/admin/branding/remove-bg" method="post" onsubmit="return confirm('Remove the login background image?');">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="ti ti-trash"></i> Remove Image
</button>
</form>
</div>
</div>
</div>
{{end}}
<form action="/admin/branding/upload" method="post" enctype="multipart/form-data">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Upload Background Image</label>
<input type="file" name="login_bg" class="form-control" accept="image/png,image/jpeg,image/webp">
<small class="form-hint">Max 5 MB. JPEG, PNG or WebP. The image is centered and fills the screen without distortion.</small>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-upload"></i> Upload
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Application Settings -->
<div class="col-12">
<div class="card">

View File

@@ -123,7 +123,7 @@
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
{{end}}
</td>
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
<td>
<div class="btn-list flex-nowrap">

View File

@@ -34,7 +34,7 @@
{{range .AuditEntries}}
<tr>
<td class="text-secondary">
<i class="ti ti-clock"></i> {{.CreatedAt.Format "2006-01-02 15:04:05"}}
<i class="ti ti-clock"></i> {{formatDateTime .CreatedAt}}
</td>
<td>
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>

View File

@@ -109,12 +109,12 @@
{{if eq .Status "done"}}
<span class="text-secondary"></span>
{{else}}
{{.NextRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
{{formatTime .NextRun}}
{{end}}
</td>
<td>
{{if .LastRun}}
{{.LastRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
{{formatTime .LastRun}}
{{else}}
<span class="text-secondary">never</span>
{{end}}

View File

@@ -202,7 +202,7 @@
<div class="mb-3 schedule-option" id="sched-once" {{if ne $job.Schedule "once"}}style="display:none;"{{end}}>
<label class="form-label required">Date & Time</label>
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at"
value="{{$job.ScheduledAt.Format "2006-01-02T15:04"}}">
value="{{formatDateTimeLocal $job.ScheduledAt}}">
<small class="form-hint">Select the exact date and time for this one-time job.</small>
</div>

View File

@@ -202,7 +202,7 @@
<tbody>
{{range .RecentAudit}}
<tr>
<td class="text-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="text-nowrap">{{formatTime .CreatedAt}}</td>
<td>{{.Username}}</td>
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>

View File

@@ -223,7 +223,7 @@
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
{{end}}
</td>
<td>{{index . "deployed_at"}}</td>
<td>{{formatDateTime (index . "deployed_at")}}</td>
</tr>
{{else}}
<tr>

View File

@@ -15,13 +15,49 @@
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="dark"] body {
background:
radial-gradient(ellipse at 20% 20%, rgba(6, 182, 212, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
#1a2234;
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:
radial-gradient(ellipse at 20% 20%, rgba(6, 182, 212, 0.10) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
#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; }
/* Dimmed subtitle */
.login-subtitle { opacity: 0.55; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
/* Glass Card Effect */
.card {
background: rgba(255, 255, 255, 0.45) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5) !important;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
[data-bs-theme="dark"] .card {
background: rgba(26, 34, 52, 0.45) !important;
border: 1px solid rgba(255, 255, 255, 0.10) !important;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.card .form-control { background: rgba(255,255,255,0.5); }
[data-bs-theme="dark"] .card .form-control { background: rgba(0,0,0,0.2); }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
@@ -31,7 +67,7 @@
<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>
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">

View File

@@ -15,13 +15,50 @@
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="dark"] body {
background:
radial-gradient(ellipse at 20% 20%, rgba(6, 182, 212, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
#1a2234;
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:
radial-gradient(ellipse at 20% 20%, rgba(6, 182, 212, 0.10) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
#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; }
/* Dimmed subtitle & footer */
.login-subtitle { opacity: 0.55; }
.login-footer { opacity: 0.55; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
/* Glass Card Effect */
.card {
background: rgba(255, 255, 255, 0.45) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5) !important;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
[data-bs-theme="dark"] .card {
background: rgba(26, 34, 52, 0.45) !important;
border: 1px solid rgba(255, 255, 255, 0.10) !important;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.card .form-control { background: rgba(255,255,255,0.5); }
[data-bs-theme="dark"] .card .form-control { background: rgba(0,0,0,0.2); }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
@@ -31,7 +68,7 @@
<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>
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
@@ -99,8 +136,8 @@
{{end}}
</div>
</div>
<div class="text-center text-secondary mt-3">
&copy; 2026 Keywarden | AGPLv3
<div class="text-center text-secondary login-footer mt-3">
&copy; 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a>
</div>
</div>
</div>

View File

@@ -47,7 +47,7 @@
</td>
<td>{{.Bits}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{formatTime .CreatedAt}}</td>
<td>
<div class="btn-list flex-nowrap">
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
@@ -89,7 +89,7 @@
</td>
<td>{{.Bits}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{formatTime .CreatedAt}}</td>
<td>
<div class="btn-list flex-nowrap">
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">

View File

@@ -8,7 +8,7 @@
<title>{{.Title}} - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<!-- Preload icon font to prevent re-decode lag on tab restore -->
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2?v3.6.0" as="font" type="font/woff2" crossorigin>
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
<script>
(function() {
@@ -63,15 +63,17 @@
overflow: hidden;
}
/* ── Full-width top header ── */
/* ── Full-width top header (glass) ── */
header.navbar.keywarden-top-header {
background: #1D2B38 !important;
background: rgba(29, 43, 56, 0.82) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
z-index: 1030;
}
[data-bs-theme="light"] header.navbar.keywarden-top-header {
background: #1D2B38 !important;
background: rgba(29, 43, 56, 0.82) !important;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
header.navbar.keywarden-top-header .nav-link { color: #c8d6e5 !important; }
@@ -119,17 +121,19 @@
border-radius: 4px;
}
/* ── Sidebar (vertical, below header) ── */
/* ── Sidebar (vertical, below header glass) ── */
.navbar-vertical {
background: #1D2B38 !important;
background: rgba(29, 43, 56, 0.82) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
flex-shrink: 0;
overflow: hidden;
align-self: stretch;
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); }
[data-bs-theme="dark"] .navbar-vertical { background: rgba(10, 17, 32, 0.82) !important; }
[data-bs-theme="dark"] header.navbar.keywarden-top-header { background: rgba(10, 17, 32, 0.82) !important; border-bottom-color: rgba(255,255,255,0.04); }
.navbar-vertical > .container-fluid {
flex: 1;
display: flex;
@@ -147,7 +151,7 @@
display: flex;
flex-direction: column;
}
[data-bs-theme="light"] .navbar-vertical { background: #1D2B38 !important; }
[data-bs-theme="light"] .navbar-vertical { background: rgba(29, 43, 56, 0.82) !important; }
/* ── Page content area ── */
.page-wrapper {
@@ -156,11 +160,29 @@
overflow-y: auto;
margin-left: 0 !important;
}
[data-bs-theme="dark"] .page-wrapper { background: #0F1829; }
[data-bs-theme="dark"] .page-body { background: #0F1829; }
[data-bs-theme="dark"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 10%, rgba(6, 182, 212, 0.07) 0%, transparent 50%),
radial-gradient(ellipse at 85% 60%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 50% 90%, rgba(168, 85, 247, 0.04) 0%, transparent 50%),
#0F1829;
}
[data-bs-theme="dark"] .page-body {
background: transparent;
}
[data-bs-theme="light"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 10%, rgba(6, 182, 212, 0.10) 0%, transparent 50%),
radial-gradient(ellipse at 85% 60%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 50% 90%, rgba(168, 85, 247, 0.06) 0%, transparent 50%),
#f1f5f9;
}
[data-bs-theme="light"] .page-body {
background: transparent;
}
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #0F1829 !important; }
.page-body { content-visibility: auto; contain-intrinsic-size: auto 500px; }
/* content-visibility removed causes Firefox to freeze/re-layout on tab hover */
/* ── Narrower dashboard stat cards ── */
.stat-card-narrow { max-width: 220px; }
@@ -424,12 +446,22 @@
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="light"] header.navbar.keywarden-top-header { background: rgba(21, 94, 117, 0.82) !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"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: rgba(7, 18, 32, 0.82) !important; }
html[data-theme-pair="ocean"][data-bs-theme="light"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 10%, rgba(6, 182, 212, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 85% 60%, rgba(14, 116, 144, 0.08) 0%, transparent 50%),
#ecfeff;
}
html[data-theme-pair="ocean"][data-bs-theme="dark"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 10%, rgba(6, 182, 212, 0.09) 0%, transparent 50%),
radial-gradient(ellipse at 85% 60%, rgba(14, 116, 144, 0.06) 0%, transparent 50%),
#0c1a2a;
}
html[data-theme-pair="ocean"] .page-body { background: transparent; }
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; }
@@ -455,12 +487,22 @@
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="light"] header.navbar.keywarden-top-header { background: rgba(20, 83, 45, 0.82) !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"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: rgba(6, 18, 9, 0.82) !important; }
html[data-theme-pair="forest"][data-bs-theme="light"] .page-wrapper {
background:
radial-gradient(ellipse at 20% 15%, rgba(74, 222, 128, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(22, 163, 74, 0.08) 0%, transparent 50%),
#f0fdf4;
}
html[data-theme-pair="forest"][data-bs-theme="dark"] .page-wrapper {
background:
radial-gradient(ellipse at 20% 15%, rgba(74, 222, 128, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(22, 163, 74, 0.05) 0%, transparent 50%),
#0a1a10;
}
html[data-theme-pair="forest"] .page-body { background: transparent; }
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; }
@@ -486,12 +528,22 @@
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="light"] header.navbar.keywarden-top-header { background: rgba(120, 53, 15, 0.82) !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"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: rgba(17, 13, 4, 0.82) !important; }
html[data-theme-pair="sunset"][data-bs-theme="light"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 20%, rgba(245, 158, 11, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(217, 119, 6, 0.08) 0%, transparent 50%),
#fffbeb;
}
html[data-theme-pair="sunset"][data-bs-theme="dark"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 20%, rgba(245, 158, 11, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(217, 119, 6, 0.05) 0%, transparent 50%),
#1a1408;
}
html[data-theme-pair="sunset"] .page-body { background: transparent; }
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; }
@@ -517,12 +569,22 @@
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="light"] header.navbar.keywarden-top-header { background: rgba(131, 24, 67, 0.82) !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"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: rgba(18, 6, 14, 0.82) !important; }
html[data-theme-pair="rose"][data-bs-theme="light"] .page-wrapper {
background:
radial-gradient(ellipse at 20% 15%, rgba(244, 114, 182, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(219, 39, 119, 0.08) 0%, transparent 50%),
#fdf2f8;
}
html[data-theme-pair="rose"][data-bs-theme="dark"] .page-wrapper {
background:
radial-gradient(ellipse at 20% 15%, rgba(244, 114, 182, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(219, 39, 119, 0.05) 0%, transparent 50%),
#1a0a14;
}
html[data-theme-pair="rose"] .page-body { background: transparent; }
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; }
@@ -548,18 +610,101 @@
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="light"] header.navbar.keywarden-top-header { background: rgba(46, 52, 64, 0.82) !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"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: rgba(20, 23, 28, 0.82) !important; }
html[data-theme-pair="nord"][data-bs-theme="light"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 10%, rgba(136, 192, 208, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(94, 129, 172, 0.08) 0%, transparent 50%),
#eceff4;
}
html[data-theme-pair="nord"][data-bs-theme="dark"] .page-wrapper {
background:
radial-gradient(ellipse at 15% 10%, rgba(136, 192, 208, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(94, 129, 172, 0.05) 0%, transparent 50%),
#1e2128;
}
html[data-theme-pair="nord"] .page-body { background: transparent; }
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; }
/* ═══════════════════════════════════════════════════════════ */
/* GLASSMORPHISM */
/* ═══════════════════════════════════════════════════════════ */
/* ── Glass Cards ── */
.page-wrapper .card {
background: rgba(255, 255, 255, 0.45) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5) !important;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
transition: box-shadow 0.25s ease, border-color 0.25s ease;
}
.page-wrapper .card:hover {
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
border-color: rgba(255, 255, 255, 0.6) !important;
}
[data-bs-theme="dark"] .page-wrapper .card {
background: rgba(15, 24, 41, 0.45) !important;
border: 1px solid rgba(255, 255, 255, 0.10) !important;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
[data-bs-theme="dark"] .page-wrapper .card:hover {
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.10);
border-color: rgba(255, 255, 255, 0.15) !important;
}
.page-wrapper .card .form-control,
.page-wrapper .card .form-select {
background: rgba(255, 255, 255, 0.5);
border-color: rgba(0, 0, 0, 0.1);
}
[data-bs-theme="dark"] .page-wrapper .card .form-control,
[data-bs-theme="dark"] .page-wrapper .card .form-select {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.08);
}
/* ── Glass Dropdown Menus ── */
.dropdown-menu {
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
background: rgba(255, 255, 255, 0.88) !important;
border: 1px solid rgba(255, 255, 255, 0.4) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
[data-bs-theme="dark"] .dropdown-menu {
background: rgba(15, 24, 41, 0.88) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* ── Glass Modal ── */
.modal-content {
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
background: rgba(255, 255, 255, 0.88) !important;
border: 1px solid rgba(255, 255, 255, 0.4) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
[data-bs-theme="dark"] .modal-content {
background: rgba(15, 24, 41, 0.88) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
</style>
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
<link rel="stylesheet" href="/static/css/tabler.min.css">
@@ -793,7 +938,7 @@
<div class="container-xl">
<div class="row text-center align-items-center">
<div class="col-12">
<span class="text-secondary">&copy; 2026 Keywarden Centralized SSH Key Management and Deployment | AGPLv3</span>
<span class="text-secondary">&copy; 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a> Centralized SSH Key Management and Deployment · <a href="{{releasesPageURL}}" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">{{appVersion}}</a>{{if updateAvailable}} · <a href="{{releaseURL}}" target="_blank" rel="noopener noreferrer" class="text-warning" title="Update verfügbar"><i class="ti ti-download"></i> {{latestVersion}} verfügbar</a>{{end}}</span>
</div>
</div>
</div>

View File

@@ -26,6 +26,51 @@
[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; }
/* Dimmed subtitle & footer on login page */
.login-subtitle { opacity: 0.55; }
.login-footer { opacity: 0.55; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
{{if loginBgImage}}
/* Custom background image */
body {
background-image: url('{{loginBgImage}}') !important;
background-size: cover !important;
background-position: center center !important;
background-repeat: no-repeat !important;
background-attachment: fixed !important;
}
{{if eq (loginTextColor) "dark"}}
/* Dark text for light background images */
.login-heading { text-shadow: 0 1px 6px rgba(255,255,255,0.7); color: #0a0f1a !important; }
.login-subtitle.text-secondary { text-shadow: 0 1px 4px rgba(255,255,255,0.6); color: rgba(10,15,26,0.85) !important; opacity: 0.75; }
.login-footer.text-secondary { text-shadow: 0 1px 4px rgba(255,255,255,0.6); color: rgba(10,15,26,0.80) !important; opacity: 0.70; }
.login-footer.text-secondary a.text-secondary { color: rgba(10,15,26,0.80) !important; }
{{else}}
/* Light text for dark background images (default) */
.login-heading { text-shadow: 0 2px 10px rgba(0,0,0,0.7); color: #fff !important; }
.login-subtitle.text-secondary { text-shadow: 0 2px 6px rgba(0,0,0,0.6); color: rgba(255,255,255,0.95) !important; opacity: 0.80; }
.login-footer.text-secondary { text-shadow: 0 2px 6px rgba(0,0,0,0.6); color: rgba(255,255,255,0.90) !important; opacity: 0.75; }
.login-footer.text-secondary a.text-secondary { color: rgba(255,255,255,0.90) !important; }
{{end}}
{{end}}
/* Glass card effect (always active) */
.card {
background: rgba(255, 255, 255, 0.15) !important;
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
[data-bs-theme="dark"] .card {
background: rgba(26, 34, 52, 0.6) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.card .form-control { background: rgba(255,255,255,0.55); }
[data-bs-theme="dark"] .card .form-control { background: rgba(0,0,0,0.3); }
.card .form-label, .card .h2, .card h2 { text-shadow: none; }
</style>
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
<link rel="stylesheet" href="/static/css/tabler.min.css">
@@ -35,8 +80,8 @@
<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>
<h1 class="login-heading"><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary login-subtitle">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
@@ -103,8 +148,8 @@
{{end}}
</div>
</div>
<div class="text-center text-secondary mt-3">
&copy; 2026 Keywarden | AGPLv3
<div class="text-center text-secondary login-footer mt-3">
&copy; 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a>
</div>
</div>
</div>

View File

@@ -15,13 +15,47 @@
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="dark"] body {
background:
radial-gradient(ellipse at 20% 20%, rgba(6, 182, 212, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
#1a2234;
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:
radial-gradient(ellipse at 20% 20%, rgba(6, 182, 212, 0.10) 0%, transparent 50%),
radial-gradient(ellipse at 80% 60%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
#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; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti { margin-right: 0; }
/* Glass Card Effect */
.card {
background: rgba(255, 255, 255, 0.45) !important;
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5) !important;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
[data-bs-theme="dark"] .card {
background: rgba(26, 34, 52, 0.45) !important;
border: 1px solid rgba(255, 255, 255, 0.10) !important;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.card .form-control { background: rgba(255,255,255,0.5); }
[data-bs-theme="dark"] .card .form-control { background: rgba(0,0,0,0.2); }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">

View File

@@ -66,6 +66,10 @@
<div class="datagrid-title">Uptime</div>
<div class="datagrid-content">{{.Uptime}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Timezone</div>
<div class="datagrid-content">{{.Timezone}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Encryption</div>
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>

View File

@@ -60,12 +60,12 @@
</td>
<td class="text-secondary">
{{if .LastLoginAt}}
{{.LastLoginAt.Format "2006-01-02 15:04"}}
{{formatTime .LastLoginAt}}
{{else}}
<span class="text-muted">Never</span>
{{end}}
</td>
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="text-secondary">{{formatTime .CreatedAt}}</td>
<td>
<div class="btn-list flex-nowrap">
{{if .LockedUntil}}

View File

@@ -79,7 +79,7 @@
<div class="d-flex align-items-center">
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
<div class="flex-fill">
<strong>Account locked</strong> until {{.EditUser.LockedUntil.Format "2006-01-02 15:04"}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
<strong>Account locked</strong> until {{formatTime .EditUser.LockedUntil}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
</div>
</div>
</div>