Merge pull request 'release/v0.4.0' (#5) from release/v0.4.0 into master
Release Docker Image / Build & Push Docker Image (release) Successful in 11m26s

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-04-09 20:58:45 +00:00
55 changed files with 1747 additions and 137 deletions
+5
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
+2
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
View File
@@ -24,6 +24,9 @@ vendor/
# AI workspace
.ki-workspace/
# Python (tools/subset-icons.py)
.venv/
# OS
.DS_Store
Thumbs.db
+7 -2
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

+10 -10
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")
+13 -4
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
+3 -2
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)
+2
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
+5 -1
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
+5 -1
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).
+6
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) |
+2
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)
+7 -1
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)
+1 -1
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
+6
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
+4 -1
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
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=
+1
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"
+26 -8
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)
+134
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)
}
}
}
+13
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,
+8 -3
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()
+36
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
}
+19 -7
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 {
+5 -2
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[:]}
}
+308 -24
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)
+19 -4
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
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()
}
}
+350
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)
}
}
}
+25 -1
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, '-')
}
+14
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
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+43
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">
+1 -1
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">
+1 -1
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>
+2 -2
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}}
+1 -1
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>
+1 -1
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>
+1 -1
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>
+39 -3
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">
+42 -5
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>
+2 -2
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}}')">
+183 -38
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>
+49 -4
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>
+36 -2
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">
+4
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>
+2 -2
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}}
+1 -1
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>