Compare commits
31 Commits
v0.2.0-alp
...
release/v0
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b3333571 | |||
| d8cb1a2f97 | |||
| 789ef6f10f | |||
| c2b96563cb | |||
| b4424b1e64 | |||
| 2f55ec84b8 | |||
| 07ea917726 | |||
| da6d66e048 | |||
| c15bac108d | |||
| b665e623f9 | |||
| dae6c6ae02 | |||
| ce36939d31 | |||
| 8a10981ecc | |||
| 34ce8a8fc3 | |||
| 3a860914d5 | |||
| dd4af5b25c | |||
| 1cf7f50bfb | |||
| ca402eb88e | |||
| fe31ef5a3c | |||
| eb1f4e0738 | |||
| 653592e68f | |||
| 465a44fae9 | |||
| 05f8698c6b | |||
| a63f3fb5ff | |||
| c4171e5b87 | |||
| 8b9de9e83d | |||
| 3a843354b6 | |||
| 61cc63d3f9 | |||
| f893d26791 | |||
| 68777a5516 | |||
| 0fcd99a191 |
@@ -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
|
||||
|
||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Ensure shell scripts always have Unix line endings (LF),
|
||||
# even when checked out on Windows.
|
||||
*.sh text eol=lf
|
||||
entrypoint.sh text eol=lf
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Lint, Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:1.26-alpine
|
||||
image: golang:1.26.2-alpine
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Go Vulnerability Check
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: golang:1.26-alpine
|
||||
image: golang:1.26.2-alpine
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,6 +24,9 @@ vendor/
|
||||
# AI workspace
|
||||
.ki-workspace/
|
||||
|
||||
# Python (tools/subset-icons.py)
|
||||
.venv/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -2,7 +2,7 @@
|
||||
# Multi-stage build for minimal image size
|
||||
|
||||
# Stage 1: Build
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.26.2-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
||||
|
||||
@@ -11,7 +11,13 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/
|
||||
|
||||
ARG VERSION=""
|
||||
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
|
||||
@@ -34,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
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
**Keywarden** is a self-hosted web application for centralized SSH key management and deployment. It lets you generate, store, and deploy SSH keys to Linux servers from a single web interface — with full audit logging, role-based access control, and automated temporary access scheduling.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
|
||||
## ⚠️ Alpha Software — Important Notice
|
||||
|
||||
@@ -23,9 +25,11 @@
|
||||
- **Temporary Access** — Schedule time-limited access with automatic expiry (key removal, user disable, or user deletion)
|
||||
- **Three-Tier Roles** — Owner, Admin, and User with distinct permissions
|
||||
- **User Invitations** — Invite users via secure email links
|
||||
- **Key Enforcement** — Bastillion-style enforced key management: automatically detect and remove unauthorized SSH keys from servers
|
||||
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
|
||||
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
|
||||
- **Audit Log** — Every action tracked with user, IP, timestamp, and details
|
||||
- **Update Notifications** — Automatic update check with version badge in the header for admins
|
||||
- **Encrypted Backup/Restore** — Full database export with password-based encryption
|
||||
- **Docker-Native** — Single container with embedded SQLite, no external database required
|
||||
|
||||
|
||||
BIN
assets/img/dashboard.png
Normal file
BIN
assets/img/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -5,10 +5,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/auth"
|
||||
@@ -23,18 +25,37 @@ import (
|
||||
"git.techniverse.net/scriptos/keywarden/internal/mail"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/version"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||
"git.techniverse.net/scriptos/keywarden/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Handle CLI subcommands before starting the server
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "reset-password":
|
||||
handleResetPassword(os.Args[2:])
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Load config first (needed for log level)
|
||||
cfg := config.Load()
|
||||
|
||||
// Set application-wide timezone from TZ environment variable
|
||||
time.Local = cfg.Timezone
|
||||
|
||||
// Initialize structured logging
|
||||
logging.Init(cfg.LogLevel)
|
||||
|
||||
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
|
||||
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", version.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.
|
||||
@@ -63,6 +84,7 @@ func main() {
|
||||
deploySvc := deploy.NewService(db)
|
||||
auditSvc := audit.NewService(db)
|
||||
cronSvc := cron.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc)
|
||||
workerSvc := worker.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc)
|
||||
mailSvc := mail.NewService(cfg)
|
||||
|
||||
// Create default owner if no users exist (password is auto-generated)
|
||||
@@ -102,8 +124,11 @@ func main() {
|
||||
logging.Info("Base URL: %s", cfg.BaseURL)
|
||||
}
|
||||
|
||||
// Initialize update checker
|
||||
updaterSvc := updater.NewService(version.Version)
|
||||
|
||||
// Setup HTTP handlers
|
||||
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL)
|
||||
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL, updaterSvc)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
@@ -113,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)
|
||||
@@ -129,6 +155,14 @@ func main() {
|
||||
cronSvc.Start()
|
||||
defer cronSvc.Stop()
|
||||
|
||||
// Start key enforcement worker
|
||||
workerSvc.Start()
|
||||
defer workerSvc.Stop()
|
||||
|
||||
// Start update checker
|
||||
updaterSvc.Start()
|
||||
defer updaterSvc.Stop()
|
||||
|
||||
// Start server
|
||||
addr := ":" + cfg.Port
|
||||
logging.Info("Server starting on http://0.0.0.0%s", addr)
|
||||
@@ -188,3 +222,86 @@ func validateDataPaths(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleResetPassword implements the "reset-password" CLI subcommand.
|
||||
// Usage: keywarden reset-password --username <name> [--reset-mfa]
|
||||
func handleResetPassword(args []string) {
|
||||
var username string
|
||||
var resetMFA bool
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--username", "-u":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
username = args[i]
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Error: --username requires a value")
|
||||
os.Exit(1)
|
||||
}
|
||||
case "--reset-mfa":
|
||||
resetMFA = true
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Error: unknown flag '%s'\n", args[i])
|
||||
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--reset-mfa]")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: --username is required")
|
||||
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--reset-mfa]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load config for DB path
|
||||
cfg := config.Load()
|
||||
|
||||
// Open database
|
||||
db, err := database.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
authSvc := auth.NewService(db)
|
||||
|
||||
// Look up the user
|
||||
user, err := authSvc.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Reset password
|
||||
newPassword, err := authSvc.ResetPassword(user.ID, resetMFA)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to reset password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("════════════════════════════════════════════════════════════")
|
||||
fmt.Printf(" Password reset successful for user: %s\n", user.Username)
|
||||
fmt.Printf(" New password: %s\n", newPassword)
|
||||
if resetMFA {
|
||||
fmt.Println(" MFA has been disabled for this account.")
|
||||
}
|
||||
fmt.Println(" The user must change this password after login.")
|
||||
fmt.Println("════════════════════════════════════════════════════════════")
|
||||
}
|
||||
|
||||
// printUsage displays available CLI subcommands
|
||||
func printUsage() {
|
||||
fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", version.Version)
|
||||
fmt.Println()
|
||||
fmt.Println("Usage:")
|
||||
fmt.Println(" keywarden Start the server")
|
||||
fmt.Println(" keywarden reset-password --username <name> Reset a user's password")
|
||||
fmt.Println(" --reset-mfa Also disable MFA")
|
||||
fmt.Println(" keywarden help Show this help")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin")
|
||||
fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa")
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ Keywarden provides a clean web UI to generate, import, and securely store SSH ke
|
||||
- **Temporary Access (Cron Jobs)** — Schedule time-limited access with automatic key removal, user disabling, or user deletion on expiry
|
||||
- **Three-Tier Role System** — Owner, Admin, and User roles with clear permission boundaries
|
||||
- **User Invitations** — Invite new users via secure email links with self-service password setup
|
||||
- **Key Enforcement** — Bastillion-style enforced key management: detect and remove unauthorized SSH keys automatically
|
||||
- **TOTP Two-Factor Authentication** — Optional or enforced MFA for all users
|
||||
- **Password Policies** — Configurable complexity requirements with account lockout
|
||||
- **Email Notifications** — Login alerts and invitation emails via SMTP
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,18 +214,29 @@ Deleting a user removes their SSH keys, server records, and all related data (CA
|
||||
|
||||
Navigate to **System** to view runtime information:
|
||||
|
||||
- Application version (with update badge if a newer release is available)
|
||||
- Go version, OS, architecture
|
||||
- CPU count, goroutine count
|
||||
- Memory allocation
|
||||
- Runtime environment (Docker or native)
|
||||
- Hostname and uptime
|
||||
|
||||
## Update Notifications
|
||||
|
||||
Keywarden automatically checks for new releases in the background by querying the Gitea releases API. If a newer version is available, a yellow update badge is displayed in the top header for **Admin** and **Owner** users. The badge links directly to the release page on Gitea.
|
||||
|
||||
The update checker is only active when the application was built with a proper version tag. Development builds without a version skip the check entirely.
|
||||
|
||||
## Admin Settings (Owner Only)
|
||||
|
||||
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
|
||||
@@ -234,6 +250,14 @@ Navigate to **Admin Settings** (owner only) to configure:
|
||||
- **Account Lockout** — Number of failed attempts before lockout and lockout duration
|
||||
- **MFA Enforcement** — Require all users to enable TOTP MFA
|
||||
|
||||
### Key Enforcement
|
||||
|
||||
- **Enforcement Mode** — Disabled (default), Monitor (log only), or Enforce (auto-remove unauthorized keys)
|
||||
- **Check Interval** — How often the worker scans servers (1–1440 minutes, default: 15)
|
||||
- **Run Now** — Trigger an immediate enforcement check
|
||||
|
||||
See [Security — Key Enforcement](security.md#key-enforcement-bastillion-style) for details.
|
||||
|
||||
### Master Key
|
||||
|
||||
- View the system master key's public key and fingerprint
|
||||
@@ -246,3 +270,29 @@ Send a test email to verify SMTP configuration.
|
||||
### Backup & Restore
|
||||
|
||||
See [Backup & Restore](backup-restore.md) for details.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
Keywarden provides CLI commands for administrative tasks that can be run via `docker exec`.
|
||||
|
||||
### Password Reset
|
||||
|
||||
Reset a user's password when they are locked out or have forgotten it:
|
||||
|
||||
```bash
|
||||
docker exec -it keywarden ./keywarden reset-password --username <name>
|
||||
```
|
||||
|
||||
This generates a new random password, prints it to the terminal, and forces the user to change it on next login. The account lockout counter is also cleared.
|
||||
|
||||
To additionally disable MFA (e.g. when the user lost their TOTP device):
|
||||
|
||||
```bash
|
||||
docker exec -it keywarden ./keywarden reset-password --username <name> --reset-mfa
|
||||
```
|
||||
|
||||
### Help
|
||||
|
||||
```bash
|
||||
docker exec -it keywarden ./keywarden help
|
||||
```
|
||||
|
||||
@@ -36,9 +36,11 @@ 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)
|
||||
worker/ ← Background key enforcement worker (Bastillion-style)
|
||||
web/
|
||||
embed.go ← Go embed directives for templates and static files
|
||||
static/ ← CSS, JS, fonts (Tabler UI framework)
|
||||
@@ -57,9 +59,10 @@ 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 HTTP server**
|
||||
13. **Start key enforcement worker** (if enabled in Admin Settings)
|
||||
14. **Start HTTP server**
|
||||
|
||||
## Database Design
|
||||
|
||||
@@ -92,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,9 @@ go mod download
|
||||
# Build
|
||||
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
|
||||
|
||||
# Build with version (optional, enables update checker)
|
||||
CGO_ENABLED=1 go build -ldflags="-X 'git.techniverse.net/scriptos/keywarden/internal/version.Version=v1.0.0'" -o keywarden ./cmd/keywarden/
|
||||
|
||||
# Run
|
||||
./keywarden
|
||||
```
|
||||
@@ -79,14 +82,19 @@ 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
|
||||
│ └── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
|
||||
│ ├── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
|
||||
│ └── updater/updater.go # Background update checker (Gitea releases API)
|
||||
├── web/
|
||||
│ ├── embed.go # Go embed directives
|
||||
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
||||
│ └── 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
|
||||
|
||||
@@ -32,6 +32,9 @@ docker compose build
|
||||
|
||||
# Or build manually
|
||||
docker build -t keywarden .
|
||||
|
||||
# Build with a specific version tag (recommended for releases)
|
||||
docker build --build-arg VERSION=v1.0.0 -t keywarden:v1.0.0 .
|
||||
```
|
||||
|
||||
### Multi-Stage Build
|
||||
@@ -43,6 +46,8 @@ The Dockerfile uses a two-stage build:
|
||||
|
||||
The runtime container runs as a non-root user (`keywarden`).
|
||||
|
||||
The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. 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
|
||||
|
||||
A complete `docker-compose.yml`:
|
||||
@@ -93,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
|
||||
@@ -127,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).
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
@@ -98,11 +100,14 @@ The **Owner** role has unrestricted access. In addition to all Admin permissions
|
||||
|
||||
#### Owner Protections
|
||||
|
||||
- **Initial owner is permanently protected**: The owner account created during installation cannot be deleted, and its role cannot be changed. This is enforced both server-side and in the UI.
|
||||
- The last owner account cannot be deleted
|
||||
- The owner can always access Admin Settings, even when MFA enforcement would otherwise redirect them (to prevent lockout)
|
||||
- On first startup, the initial account is always created with the `owner` role
|
||||
- If no owner exists (e.g., after a migration from an older version), the first admin is automatically promoted to owner
|
||||
|
||||
> **Note:** Existing installations are automatically migrated — the oldest owner (by ID) is marked as the initial owner during the database migration.
|
||||
|
||||
## Audit Log Visibility
|
||||
|
||||
The audit log has role-based filtering:
|
||||
|
||||
@@ -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)
|
||||
@@ -209,3 +215,58 @@ When deploying keys to servers, Keywarden:
|
||||
8. **Network isolation**: Restrict access to Keywarden and managed servers to trusted networks
|
||||
9. **Keep the encryption key safe**: Back up `KEYWARDEN_ENCRYPTION_KEY` securely — losing it means losing all private keys
|
||||
10. **Monitor the audit log**: Review login activity and deployment actions regularly
|
||||
11. **Enable key enforcement**: Use enforce mode to ensure only Keywarden-managed keys exist on your servers
|
||||
|
||||
## Key Enforcement (Bastillion-Style)
|
||||
|
||||
Keywarden includes an enforced key management feature inspired by [Bastillion](https://www.bastillion.io/). When enabled, a background worker periodically connects to all managed servers and ensures that only authorized SSH keys are present in `authorized_keys` files.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. The enforcement worker runs at a configurable interval (default: 15 minutes)
|
||||
2. For each managed server and system user, it reads the current `authorized_keys`
|
||||
3. It compares the keys against the **desired state** derived from:
|
||||
- All active access assignments (desired_state = "present")
|
||||
- All active cron jobs (temporary access that has not yet expired)
|
||||
- All direct key deployments (via the Deploy page)
|
||||
- The system master key (always authorized)
|
||||
4. Unauthorized keys (not managed by Keywarden) are detected
|
||||
5. Depending on the mode, unauthorized keys are either logged or removed
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **Disabled** | No enforcement checks (default) |
|
||||
| **Monitor** | Detects unauthorized keys and logs them in the audit log, but does not remove them |
|
||||
| **Enforce** | Detects unauthorized keys and **removes them automatically**, replacing `authorized_keys` with only the authorized set |
|
||||
|
||||
### Configuration
|
||||
|
||||
Key enforcement is configured in **Admin Settings → Key Enforcement**:
|
||||
|
||||
- **Enforcement Mode**: Disabled / Monitor / Enforce
|
||||
- **Check Interval**: How often the worker checks servers (1–1440 minutes)
|
||||
- **Run Now**: Trigger an immediate enforcement check
|
||||
|
||||
### Audit Trail
|
||||
|
||||
All enforcement actions are recorded in the audit log:
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `enforcement_run` | An enforcement cycle completed (with summary) |
|
||||
| `enforcement_drift` | Unauthorized keys detected on a server |
|
||||
| `enforcement_applied` | Unauthorized keys were removed from a server |
|
||||
| `enforcement_failed` | An enforcement action failed (connection error, etc.) |
|
||||
| `enforcement_settings_changed` | Enforcement settings were modified |
|
||||
|
||||
### Important Notes
|
||||
|
||||
- The system master key is **always** considered authorized and will never be removed
|
||||
- Enforcement covers all system users that have active access assignments, cron jobs, or direct deployments in Keywarden
|
||||
- The server's admin user (used for SSH connections) is always checked
|
||||
- Enforcement requires the system master key to be deployed on target servers
|
||||
- In **enforce** mode, `authorized_keys` is atomically replaced (write to temp file, then move)
|
||||
- Manual runs can be triggered from the Admin Settings page
|
||||
|
||||
|
||||
@@ -27,12 +27,13 @@ Common issues and solutions for Keywarden.
|
||||
|
||||
**Solutions**:
|
||||
- Check the very first startup logs: `docker compose logs keywarden`
|
||||
- If you missed the password, delete the database and restart to trigger a fresh setup:
|
||||
- Reset the password via CLI command (no restart needed):
|
||||
```bash
|
||||
docker compose down
|
||||
docker volume rm keywarden_keywarden_data
|
||||
docker compose up -d
|
||||
docker compose logs keywarden
|
||||
docker exec -it keywarden ./keywarden reset-password --username admin
|
||||
```
|
||||
- If MFA is also lost, add `--reset-mfa`:
|
||||
```bash
|
||||
docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa
|
||||
```
|
||||
|
||||
## Login Issues
|
||||
@@ -50,7 +51,10 @@ Common issues and solutions for Keywarden.
|
||||
**Solutions**:
|
||||
- Wait for the lockout period to expire (default: 15 minutes)
|
||||
- Ask an administrator to unlock the account from the user management page
|
||||
- If you're the only owner: wait for the lockout to expire, or delete and recreate the database
|
||||
- If you're the only owner: reset your password via CLI (this also clears lockout):
|
||||
```bash
|
||||
docker exec -it keywarden ./keywarden reset-password --username admin
|
||||
```
|
||||
|
||||
### MFA Code Invalid
|
||||
|
||||
|
||||
@@ -57,11 +57,11 @@ The **Keys** page lists all your SSH keys with:
|
||||
|
||||
Admins and owners see all keys in the system, grouped by owner.
|
||||
|
||||
### Downloading Keys
|
||||
### Viewing and Downloading Keys
|
||||
|
||||
From the key list, you can download:
|
||||
- **Public Key** — For deployment to servers
|
||||
- **Private Key** — Decrypted and downloaded (use with caution)
|
||||
From the key list, you can:
|
||||
- **View Public Key** — Opens a modal overlay showing the public key with a copy-to-clipboard button
|
||||
- **Download Private Key** — Decrypted and downloaded (use with caution)
|
||||
|
||||
### Deleting Keys
|
||||
|
||||
@@ -88,11 +88,23 @@ Navigate to **Settings** to manage your account:
|
||||
|
||||
### Theme
|
||||
|
||||
Choose between:
|
||||
- **Auto** — Follows your system/browser preference
|
||||
KeyWarden offers five color themes, each available in three modes:
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| **Ocean** (default) | Cyan/teal accent |
|
||||
| **Forest** | Green accent |
|
||||
| **Sunset** | Amber/orange accent |
|
||||
| **Rose** | Pink accent |
|
||||
| **Nord** | Cool blue-gray accent |
|
||||
|
||||
Each theme supports:
|
||||
- **System** — Follows your system/browser preference (light or dark)
|
||||
- **Light** — Always light mode
|
||||
- **Dark** — Always dark mode
|
||||
|
||||
> Existing installations using the previous theme values (`auto`, `light`, `dark`) are automatically migrated to the Ocean theme.
|
||||
|
||||
### Password Change
|
||||
|
||||
Change your password. The new password must comply with the configured password policy (displayed on the form).
|
||||
@@ -122,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
7
go.mod
7
go.mod
@@ -1,6 +1,6 @@
|
||||
module git.techniverse.net/scriptos/keywarden
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/cloudflare/circl v1.6.3
|
||||
@@ -8,4 +8,7 @@ require (
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.42.0 // indirect
|
||||
require (
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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=
|
||||
|
||||
@@ -61,6 +61,7 @@ const (
|
||||
ActionMasterKeyRegenerated = "masterkey_regenerated"
|
||||
ActionMasterKeyRegenFailed = "masterkey_regen_failed"
|
||||
ActionAvatarChanged = "avatar_changed"
|
||||
ActionBrandingChanged = "branding_changed"
|
||||
|
||||
// Email
|
||||
ActionEmailNotifyChanged = "email_notify_changed"
|
||||
@@ -107,6 +108,13 @@ const (
|
||||
ActionInvitationSendFailed = "invitation_send_failed"
|
||||
ActionInvitationAccepted = "invitation_accepted"
|
||||
ActionInvitationFailed = "invitation_failed"
|
||||
|
||||
// Key Enforcement
|
||||
ActionEnforcementRun = "enforcement_run"
|
||||
ActionEnforcementDrift = "enforcement_drift"
|
||||
ActionEnforcementApplied = "enforcement_applied"
|
||||
ActionEnforcementFailed = "enforcement_failed"
|
||||
ActionEnforcementSettings = "enforcement_settings_changed"
|
||||
)
|
||||
|
||||
// AuditEntry extends AuditLog with the username for display
|
||||
|
||||
@@ -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 {
|
||||
@@ -104,6 +112,59 @@ func (s *Service) Login(username, password string) (*models.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByUsername returns a user by their username
|
||||
func (s *Service) GetUserByUsername(username string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE username = ?`,
|
||||
username,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ResetPassword generates a new random password for the given user, sets
|
||||
// must_change_password = true, resets lockout counters and optionally
|
||||
// disables MFA. Returns the generated password.
|
||||
func (s *Service) ResetPassword(userID int64, resetMFA bool) (string, error) {
|
||||
password, err := generateSecurePassword(20)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate password: %w", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET password_hash = ?, must_change_password = 1, failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
string(hash), userID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
if resetMFA {
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reset MFA: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// GetUserByID returns a user by their ID
|
||||
func (s *Service) GetUserByID(id int64) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
@@ -187,7 +248,7 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
|
||||
return false, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`,
|
||||
username, email, string(hash), "owner",
|
||||
)
|
||||
@@ -195,6 +256,11 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
// Store the ID of the initial owner so it can never be deleted or downgraded.
|
||||
if ownerID, idErr := result.LastInsertId(); idErr == nil {
|
||||
s.markInitialOwner(ownerID)
|
||||
}
|
||||
|
||||
// Mark initial setup as complete so the password is never regenerated.
|
||||
s.markInitialSetupComplete()
|
||||
|
||||
@@ -209,6 +275,43 @@ func (s *Service) isInitialSetupComplete() bool {
|
||||
return err == nil && val == "true"
|
||||
}
|
||||
|
||||
// markInitialOwner stores the user ID of the initial owner in the settings table.
|
||||
func (s *Service) markInitialOwner(userID int64) {
|
||||
s.db.Exec(
|
||||
`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', ?, CURRENT_TIMESTAMP)`,
|
||||
fmt.Sprintf("%d", userID),
|
||||
)
|
||||
}
|
||||
|
||||
// IsInitialOwner returns true if the given user ID is the initial owner
|
||||
// created during installation. This owner cannot be deleted or downgraded.
|
||||
func (s *Service) IsInitialOwner(userID int64) bool {
|
||||
var val string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&val)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
stored, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return stored == userID
|
||||
}
|
||||
|
||||
// GetInitialOwnerID returns the user ID of the initial owner, or 0 if not set.
|
||||
func (s *Service) GetInitialOwnerID() int64 {
|
||||
var val string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&val)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// markInitialSetupComplete persists the initial-setup flag in the settings table.
|
||||
func (s *Service) markInitialSetupComplete() {
|
||||
s.db.Exec(
|
||||
@@ -216,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)
|
||||
@@ -306,10 +419,26 @@ func (s *Service) DisableMFA(userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTheme updates the user's theme preference (auto, light, dark)
|
||||
// UpdateTheme updates the user's theme preference
|
||||
func (s *Service) UpdateTheme(id int64, theme string) error {
|
||||
if theme != "auto" && theme != "light" && theme != "dark" {
|
||||
theme = "auto"
|
||||
// Map legacy default values to ocean
|
||||
switch theme {
|
||||
case "auto", "":
|
||||
theme = "ocean-auto"
|
||||
case "light":
|
||||
theme = "ocean-light"
|
||||
case "dark":
|
||||
theme = "ocean-dark"
|
||||
}
|
||||
validThemes := map[string]bool{
|
||||
"ocean-auto": true, "ocean-light": true, "ocean-dark": true,
|
||||
"forest-auto": true, "forest-light": true, "forest-dark": true,
|
||||
"sunset-auto": true, "sunset-light": true, "sunset-dark": true,
|
||||
"rose-auto": true, "rose-light": true, "rose-dark": true,
|
||||
"nord-auto": true, "nord-light": true, "nord-dark": true,
|
||||
}
|
||||
if !validThemes[theme] {
|
||||
theme = "ocean-auto"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
|
||||
@@ -418,3 +418,100 @@ func TestEnableDisableMFA(t *testing.T) {
|
||||
t.Fatal("MFA should be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByUsername(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
|
||||
|
||||
user, err := svc.GetUserByUsername("testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername failed: %v", err)
|
||||
}
|
||||
if user.ID != created.ID {
|
||||
t.Fatalf("Expected user ID %d, got %d", created.ID, user.ID)
|
||||
}
|
||||
if user.Username != "testuser" {
|
||||
t.Fatalf("Expected username 'testuser', got %q", user.Username)
|
||||
}
|
||||
|
||||
// Non-existent user
|
||||
_, err = svc.GetUserByUsername("nonexistent")
|
||||
if err != ErrUserNotFound {
|
||||
t.Fatalf("Expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPassword(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
|
||||
|
||||
// Reset without MFA reset
|
||||
newPass, err := svc.ResetPassword(created.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetPassword failed: %v", err)
|
||||
}
|
||||
if len(newPass) != 20 {
|
||||
t.Fatalf("Expected 20-char password, got %d chars", len(newPass))
|
||||
}
|
||||
|
||||
// Old password should fail
|
||||
_, err = svc.Login("testuser", "oldpass")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Fatal("Old password should no longer work after reset")
|
||||
}
|
||||
|
||||
// New password should work
|
||||
user, err := svc.Login("testuser", newPass)
|
||||
if err != nil {
|
||||
t.Fatalf("Login with reset password failed: %v", err)
|
||||
}
|
||||
if !user.MustChangePassword {
|
||||
t.Fatal("must_change_password should be set after reset")
|
||||
}
|
||||
|
||||
// Account lockout should be cleared
|
||||
if user.FailedLoginAttempts != 0 {
|
||||
t.Fatalf("Expected 0 failed attempts after reset, got %d", user.FailedLoginAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordWithMFA(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
|
||||
|
||||
// Enable MFA
|
||||
svc.EnableMFA(created.ID, "TESTSECRET")
|
||||
|
||||
// Reset with MFA reset
|
||||
newPass, err := svc.ResetPassword(created.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetPassword with MFA reset failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify MFA is disabled
|
||||
user, err := svc.GetUserByID(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID failed: %v", err)
|
||||
}
|
||||
if user.MFAEnabled {
|
||||
t.Fatal("MFA should be disabled after reset with --reset-mfa")
|
||||
}
|
||||
if user.MFASecret != "" {
|
||||
t.Fatalf("MFA secret should be empty after reset, got %q", user.MFASecret)
|
||||
}
|
||||
|
||||
// New password should work
|
||||
_, err = svc.Login("testuser", newPass)
|
||||
if err != nil {
|
||||
t.Fatalf("Login with reset password failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
134
internal/auth/security_test.go
Normal file
134
internal/auth/security_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -246,5 +247,60 @@ func (d *DB) migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: backfill initial_owner_id for existing installations
|
||||
{
|
||||
var migCount int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'backfill_initial_owner_id'`).Scan(&migCount)
|
||||
if migCount == 0 {
|
||||
// Only set if not already present (new installs set it in EnsureAdmin)
|
||||
var existing string
|
||||
err := d.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&existing)
|
||||
if err != nil || existing == "" {
|
||||
// Pick the oldest owner (lowest ID) as the initial owner
|
||||
var ownerID int64
|
||||
err := d.QueryRow(`SELECT id FROM users WHERE role = 'owner' ORDER BY id ASC LIMIT 1`).Scan(&ownerID)
|
||||
if err == nil && ownerID > 0 {
|
||||
d.Exec(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', CAST(? AS TEXT), CURRENT_TIMESTAMP)`, ownerID)
|
||||
}
|
||||
}
|
||||
d.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_initial_owner_id')`)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -655,3 +667,110 @@ func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error)
|
||||
}
|
||||
return deployments, nil
|
||||
}
|
||||
|
||||
// ReadAuthorizedKeys reads the current authorized_keys for a system user on a remote server.
|
||||
// Returns the list of key lines (non-empty, non-comment lines).
|
||||
func (s *Service) ReadAuthorizedKeys(server *models.Server, authPrivateKey []byte, systemUser string) ([]string, error) {
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
||||
if systemUser == "root" {
|
||||
homeDir = "/root"
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(`cat %s/.ssh/authorized_keys 2>/dev/null || echo ""`, homeDir)
|
||||
output, err := session.Output(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read authorized_keys: %w", err)
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
keys = append(keys, line)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// WriteAuthorizedKeys replaces the entire authorized_keys file for a system user on a remote server
|
||||
// with the provided set of keys. This is the enforcement function.
|
||||
func (s *Service) WriteAuthorizedKeys(server *models.Server, authPrivateKey []byte, systemUser string, authorizedKeys []string) error {
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
||||
if systemUser == "root" {
|
||||
homeDir = "/root"
|
||||
}
|
||||
|
||||
// Build the authorized_keys content
|
||||
content := strings.Join(authorizedKeys, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
// Use printf to write the content to avoid shell interpretation issues
|
||||
// First write to a temp file, then atomically move it
|
||||
escapedContent := strings.ReplaceAll(content, "'", "'\\''")
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && printf '%%s' '%s' > %s/.ssh/authorized_keys.tmp && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown '%s':'%s' %s/.ssh/authorized_keys`,
|
||||
homeDir, homeDir, escapedContent, homeDir, homeDir, homeDir, homeDir, systemUser, systemUser, homeDir,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
return fmt.Errorf("failed to write authorized_keys: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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[:]}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -39,6 +45,8 @@ import (
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||
)
|
||||
|
||||
// sessionData holds session metadata for timeout tracking
|
||||
@@ -56,7 +64,9 @@ type Handler struct {
|
||||
deploy *deploy.Service
|
||||
audit *audit.Service
|
||||
cron *cron.Service
|
||||
worker *worker.Service
|
||||
mail *mail.Service
|
||||
updater *updater.Service
|
||||
db *database.DB // direct database access for backup/restore
|
||||
templates map[string]*template.Template
|
||||
sessions map[string]*sessionData // cookie -> session data with timeout tracking
|
||||
@@ -169,6 +179,13 @@ type PageData struct {
|
||||
|
||||
// System Information
|
||||
SystemInfo *SystemInfo
|
||||
|
||||
// Key Enforcement
|
||||
EnforcementStatus map[string]string
|
||||
|
||||
// Initial Owner protection
|
||||
IsInitialOwner bool
|
||||
InitialOwnerID int64
|
||||
}
|
||||
|
||||
// SystemInfo holds runtime system information for the settings page
|
||||
@@ -183,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
|
||||
@@ -242,7 +260,7 @@ func formatUptime(start time.Time) string {
|
||||
}
|
||||
|
||||
// New creates a new Handler
|
||||
func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string) *Handler {
|
||||
func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string, updaterSvc *updater.Service) *Handler {
|
||||
// Create sub-FS so /static/css/... maps to static/css/... in embed
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
@@ -255,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,
|
||||
@@ -262,7 +286,9 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
|
||||
deploy: deploySvc,
|
||||
audit: auditSvc,
|
||||
cron: cronSvc,
|
||||
worker: workerSvc,
|
||||
mail: mailSvc,
|
||||
updater: updaterSvc,
|
||||
db: db,
|
||||
sessions: make(map[string]*sessionData),
|
||||
pending: make(map[string]int64),
|
||||
@@ -292,6 +318,71 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
||||
}
|
||||
return name
|
||||
},
|
||||
"appVersion": func() string {
|
||||
return h.updater.CurrentVersion()
|
||||
},
|
||||
"updateAvailable": func() bool {
|
||||
return h.updater.HasUpdate()
|
||||
},
|
||||
"latestVersion": func() string {
|
||||
return h.updater.LatestVersion()
|
||||
},
|
||||
"releaseURL": func() string {
|
||||
return h.updater.ReleaseURL()
|
||||
},
|
||||
"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")
|
||||
@@ -374,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)
|
||||
@@ -429,9 +521,12 @@ 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))
|
||||
mux.HandleFunc("/admin/enforcement/run", h.requireOwner(h.handleEnforcementRunNow))
|
||||
}
|
||||
|
||||
// handleAPIHealth returns a JSON health status (no auth required).
|
||||
@@ -541,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
|
||||
@@ -646,6 +743,11 @@ func isOwner(role string) bool {
|
||||
return role == "owner"
|
||||
}
|
||||
|
||||
// getInitialOwnerID returns the user ID of the initial owner (0 if not set)
|
||||
func (h *Handler) getInitialOwnerID() int64 {
|
||||
return h.auth.GetInitialOwnerID()
|
||||
}
|
||||
|
||||
func (h *Handler) getUserID(r *http.Request) int64 {
|
||||
id, _ := strconv.ParseInt(r.Header.Get("X-User-ID"), 10, 64)
|
||||
return id
|
||||
@@ -825,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)
|
||||
@@ -1399,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)
|
||||
@@ -1407,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",
|
||||
@@ -1426,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)
|
||||
@@ -1477,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) {
|
||||
@@ -1492,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)
|
||||
@@ -1511,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)
|
||||
@@ -1566,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)
|
||||
@@ -1819,10 +1993,11 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
data := &PageData{
|
||||
Title: "User Management",
|
||||
Active: "users",
|
||||
User: user,
|
||||
Users: users,
|
||||
Title: "User Management",
|
||||
Active: "users",
|
||||
User: user,
|
||||
Users: users,
|
||||
InitialOwnerID: h.getInitialOwnerID(),
|
||||
}
|
||||
h.templates["users"].ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
@@ -1995,6 +2170,7 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
User: user,
|
||||
EditUser: targetUser,
|
||||
PasswordPolicy: &policy,
|
||||
IsInitialOwner: h.auth.IsInitialOwner(targetID),
|
||||
}
|
||||
h.templates["users_edit"].ExecuteTemplate(w, "base", data)
|
||||
return
|
||||
@@ -2007,6 +2183,22 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
newPassword := r.FormValue("password")
|
||||
forceChange := r.FormValue("must_change_password") == "1"
|
||||
|
||||
// Initial Owner protection: role must remain "owner"
|
||||
if h.auth.IsInitialOwner(targetID) && role != "owner" {
|
||||
policy := h.auth.GetPasswordPolicy()
|
||||
data := &PageData{
|
||||
Title: "Edit User",
|
||||
Active: "users",
|
||||
User: user,
|
||||
EditUser: targetUser,
|
||||
PasswordPolicy: &policy,
|
||||
IsInitialOwner: true,
|
||||
Flash: &Flash{Type: "danger", Message: "The initial owner role cannot be changed. This account was created during installation and is permanently protected."},
|
||||
}
|
||||
h.templates["users_edit"].ExecuteTemplate(w, "base", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce role restrictions:
|
||||
// - Admin can only assign "user" role
|
||||
// - Only owner can assign "admin" or "owner"
|
||||
@@ -2100,6 +2292,11 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
case "delete":
|
||||
if r.Method == http.MethodPost {
|
||||
// Initial Owner protection: cannot be deleted
|
||||
if h.auth.IsInitialOwner(targetID) {
|
||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Owner protection: cannot self-delete
|
||||
if targetID == userID {
|
||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||
@@ -2426,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
|
||||
}
|
||||
@@ -2962,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{
|
||||
@@ -2973,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 0–65535)
|
||||
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)
|
||||
@@ -2993,6 +3331,7 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
EmailEnabled: h.mail.IsEnabled(),
|
||||
MasterKeyPublic: masterPub,
|
||||
MasterKeyFingerprint: masterFP,
|
||||
EnforcementStatus: h.worker.GetStatus(),
|
||||
}
|
||||
|
||||
// Check for flash message from query parameters (e.g. after backup restore)
|
||||
@@ -3047,6 +3386,31 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if len(changed) > 0 {
|
||||
h.audit.Log(userID, audit.ActionPasswordPolicyChanged, fmt.Sprintf("Security settings updated: %s", strings.Join(changed, ", ")), clientIP(r))
|
||||
}
|
||||
case "enforcement_settings":
|
||||
// Key enforcement settings
|
||||
batch := make(map[string]string)
|
||||
enforceMode := r.FormValue("enforce_mode")
|
||||
if enforceMode == "" {
|
||||
enforceMode = "disabled"
|
||||
}
|
||||
batch["enforce_mode"] = enforceMode
|
||||
changed = append(changed, "enforce_mode="+enforceMode)
|
||||
|
||||
enforceInterval := r.FormValue("enforce_interval")
|
||||
if enforceInterval == "" {
|
||||
enforceInterval = "15"
|
||||
}
|
||||
batch["enforce_interval"] = enforceInterval
|
||||
changed = append(changed, "enforce_interval="+enforceInterval)
|
||||
|
||||
if err := h.auth.SetSettingsBatch(batch); err != nil {
|
||||
logging.Error("Failed to save enforcement settings: %v", err)
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=danger&flash_msg="+url.QueryEscape("Failed to save enforcement settings: "+err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if len(changed) > 0 {
|
||||
h.audit.Log(userID, audit.ActionEnforcementSettings, fmt.Sprintf("Enforcement settings updated: %s", strings.Join(changed, ", ")), clientIP(r))
|
||||
}
|
||||
default:
|
||||
// Application settings (existing behavior)
|
||||
batch := make(map[string]string)
|
||||
@@ -3124,6 +3488,22 @@ func (h *Handler) handleMasterKeyRegenerate(w http.ResponseWriter, r *http.Reque
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("System master key successfully regenerated."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleEnforcementRunNow triggers an immediate key enforcement run (owner only)
|
||||
func (h *Handler) handleEnforcementRunNow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
userID := h.getUserID(r)
|
||||
logging.Info("Key enforcement manual run triggered by user_id=%d", userID)
|
||||
h.audit.Log(userID, audit.ActionEnforcementRun, "Manual key enforcement run triggered", clientIP(r))
|
||||
|
||||
h.worker.RunNow()
|
||||
|
||||
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("Key enforcement run started. Check the audit log for results."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Cron Job Handlers ---
|
||||
|
||||
// handleAPICronAssignments returns assignments for a given user as JSON (for AJAX).
|
||||
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
htmltpl "html/template"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"text/template"
|
||||
texttpl "text/template"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/config"
|
||||
@@ -66,7 +67,7 @@ func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationDa
|
||||
return nil
|
||||
}
|
||||
|
||||
htmlBody, err := renderTemplate(loginNotificationHTML, data)
|
||||
htmlBody, err := renderHTMLTemplate(loginNotificationHTML, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render HTML template: %w", err)
|
||||
}
|
||||
@@ -108,7 +109,7 @@ func (s *Service) SendInvitation(toEmail string, data InvitationData) error {
|
||||
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
|
||||
}
|
||||
|
||||
htmlBody, err := renderTemplate(invitationHTML, data)
|
||||
htmlBody, err := renderHTMLTemplate(invitationHTML, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render invitation HTML template: %w", err)
|
||||
}
|
||||
@@ -317,7 +318,21 @@ func (s *Service) send(to string, msg []byte) error {
|
||||
}
|
||||
|
||||
func renderTemplate(tmplStr string, data interface{}) (string, error) {
|
||||
tmpl, err := template.New("email").Parse(tmplStr)
|
||||
tmpl, err := texttpl.New("email").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// renderHTMLTemplate uses html/template for proper context-aware escaping,
|
||||
// preventing XSS in HTML email bodies when user-supplied data is included.
|
||||
func renderHTMLTemplate(tmplStr string, data interface{}) (string, error) {
|
||||
tmpl, err := htmltpl.New("email").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
112
internal/security/gzip.go
Normal file
112
internal/security/gzip.go
Normal 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
internal/security/security_test.go
Normal file
350
internal/security/security_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
217
internal/updater/updater.go
Normal file
217
internal/updater/updater.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
)
|
||||
|
||||
const (
|
||||
// Gitea API endpoint for releases
|
||||
releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5"
|
||||
// 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
|
||||
httpTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// giteaRelease represents the relevant fields from the Gitea releases API
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// Service checks for new releases in the background
|
||||
type Service struct {
|
||||
currentVersion string
|
||||
|
||||
mu sync.RWMutex
|
||||
latestVersion string
|
||||
releaseURL string
|
||||
hasUpdate bool
|
||||
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewService creates an update checker. Pass the current application version
|
||||
// (e.g. "v1.0.0" or "dev"). The checker runs in the background and queries
|
||||
// the Gitea releases API periodically.
|
||||
func NewService(currentVersion string) *Service {
|
||||
return &Service{
|
||||
currentVersion: currentVersion,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic update checks in the background.
|
||||
func (s *Service) Start() {
|
||||
// Don't check if running a dev build
|
||||
if s.currentVersion == "" || s.currentVersion == "dev" {
|
||||
logging.Info("Update checker disabled (development build)")
|
||||
return
|
||||
}
|
||||
logging.Info("Update checker started (current version: %s, checking every %s)", s.currentVersion, checkInterval)
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop signals the background goroutine to exit.
|
||||
func (s *Service) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// HasUpdate returns true if a newer version is available.
|
||||
func (s *Service) HasUpdate() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.hasUpdate
|
||||
}
|
||||
|
||||
// LatestVersion returns the tag name of the latest release (e.g. "v1.2.0").
|
||||
func (s *Service) LatestVersion() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.latestVersion
|
||||
}
|
||||
|
||||
// ReleaseURL returns the HTML link to the latest release page on Gitea.
|
||||
func (s *Service) ReleaseURL() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.releaseURL
|
||||
}
|
||||
|
||||
// CurrentVersion returns the running application version.
|
||||
func (s *Service) CurrentVersion() string {
|
||||
return s.currentVersion
|
||||
}
|
||||
|
||||
func (s *Service) run() {
|
||||
// Initial check shortly after startup
|
||||
timer := time.NewTimer(30 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-timer.C:
|
||||
s.check()
|
||||
timer.Reset(checkInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) check() {
|
||||
client := &http.Client{Timeout: httpTimeout}
|
||||
|
||||
resp, err := client.Get(releasesAPI)
|
||||
if err != nil {
|
||||
logging.Warn("Update check failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logging.Warn("Update check: Gitea API returned status %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var releases []giteaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
logging.Warn("Update check: failed to parse response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the latest stable release (not draft, not prerelease)
|
||||
for _, rel := range releases {
|
||||
if rel.Draft || rel.Prerelease || rel.TagName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.latestVersion = rel.TagName
|
||||
s.releaseURL = rel.HTMLURL
|
||||
s.hasUpdate = isNewer(rel.TagName, s.currentVersion)
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.HasUpdate() {
|
||||
logging.Info("New version available: %s (current: %s)", rel.TagName, s.currentVersion)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// isNewer returns true if latest is a higher version than current.
|
||||
// Both may optionally have a "v" prefix (e.g. "v1.2.3").
|
||||
// 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)
|
||||
|
||||
for i := 0; i < len(latestParts) || i < len(currentParts); i++ {
|
||||
l, c := 0, 0
|
||||
if i < len(latestParts) {
|
||||
l = latestParts[i]
|
||||
}
|
||||
if i < len(currentParts) {
|
||||
c = currentParts[i]
|
||||
}
|
||||
if l > c {
|
||||
return true
|
||||
}
|
||||
if l < c {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
nums = append(nums, n)
|
||||
}
|
||||
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
internal/version/version.go
Normal file
14
internal/version/version.go
Normal 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"
|
||||
672
internal/worker/worker.go
Normal file
672
internal/worker/worker.go
Normal file
@@ -0,0 +1,672 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package worker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/deploy"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/keys"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||
)
|
||||
|
||||
// Mode defines the enforcement behavior
|
||||
const (
|
||||
ModeDisabled = "disabled" // no enforcement
|
||||
ModeMonitor = "monitor" // detect unauthorized keys, log only
|
||||
ModeEnforce = "enforce" // detect + remove unauthorized keys
|
||||
)
|
||||
|
||||
// DefaultInterval is the default enforcement check interval in minutes
|
||||
const DefaultInterval = 15
|
||||
|
||||
// Service handles the background key enforcement worker
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
deploy *deploy.Service
|
||||
keys *keys.Service
|
||||
servers *servers.Service
|
||||
audit *audit.Service
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewService creates a new enforcement worker service
|
||||
func NewService(db *database.DB, deploySvc *deploy.Service, keysSvc *keys.Service, serversSvc *servers.Service, auditSvc *audit.Service) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
deploy: deploySvc,
|
||||
keys: keysSvc,
|
||||
servers: serversSvc,
|
||||
audit: auditSvc,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the enforcement worker loop
|
||||
func (s *Service) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
// Check settings every 60 seconds to see if enforcement is enabled
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastRun time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
mode := s.getMode()
|
||||
if mode == ModeDisabled {
|
||||
continue
|
||||
}
|
||||
interval := s.getInterval()
|
||||
if time.Since(lastRun) >= time.Duration(interval)*time.Minute {
|
||||
s.runEnforcement(mode)
|
||||
lastRun = time.Now()
|
||||
}
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
logging.Info("Key enforcement worker started (checks settings every 60s)")
|
||||
}
|
||||
|
||||
// Stop gracefully stops the enforcement worker
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
close(s.stopCh)
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// RunNow triggers an immediate enforcement run (e.g. from admin UI)
|
||||
func (s *Service) RunNow() {
|
||||
mode := s.getMode()
|
||||
if mode == ModeDisabled {
|
||||
logging.Warn("Key enforcement: manual run requested but enforcement is disabled")
|
||||
return
|
||||
}
|
||||
go s.runEnforcement(mode)
|
||||
}
|
||||
|
||||
// getMode reads the enforcement mode from settings
|
||||
func (s *Service) getMode() string {
|
||||
var val string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_mode'`).Scan(&val)
|
||||
if err != nil || val == "" {
|
||||
return ModeDisabled
|
||||
}
|
||||
switch val {
|
||||
case ModeMonitor, ModeEnforce:
|
||||
return val
|
||||
default:
|
||||
return ModeDisabled
|
||||
}
|
||||
}
|
||||
|
||||
// getInterval reads the enforcement interval from settings (in minutes)
|
||||
func (s *Service) getInterval() int {
|
||||
var val string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_interval'`).Scan(&val)
|
||||
if err != nil || val == "" {
|
||||
return DefaultInterval
|
||||
}
|
||||
var interval int
|
||||
fmt.Sscanf(val, "%d", &interval)
|
||||
if interval < 1 {
|
||||
return DefaultInterval
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
// runEnforcement performs one enforcement cycle across all managed servers
|
||||
func (s *Service) runEnforcement(mode string) {
|
||||
logging.Info("Key enforcement: starting run (mode=%s)", mode)
|
||||
|
||||
// Get system master key
|
||||
masterKeyPEM, err := s.keys.GetSystemMasterKeyPrivate()
|
||||
if err != nil {
|
||||
logging.Error("Key enforcement: cannot get system master key: %v", err)
|
||||
return
|
||||
}
|
||||
masterKeyPub, err := s.keys.GetSystemMasterKeyPublic()
|
||||
if err != nil {
|
||||
logging.Error("Key enforcement: cannot get system master key public: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all servers
|
||||
allServers, err := s.servers.GetAllServers()
|
||||
if err != nil {
|
||||
logging.Error("Key enforcement: failed to get servers: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(allServers) == 0 {
|
||||
logging.Debug("Key enforcement: no servers configured, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Build desired-state map: server_id -> system_user -> []public_key
|
||||
desiredKeys := s.buildDesiredState(masterKeyPub)
|
||||
|
||||
var totalChecked, totalUnauthorized, totalRemoved, totalErrors int
|
||||
|
||||
for _, srv := range allServers {
|
||||
server := srv
|
||||
// For each server, determine which system users to check
|
||||
usersToCheck := s.getSystemUsersForServer(server.ID)
|
||||
// Always check the server's default admin user
|
||||
if _, exists := usersToCheck[server.Username]; !exists {
|
||||
usersToCheck[server.Username] = true
|
||||
}
|
||||
|
||||
for systemUser := range usersToCheck {
|
||||
checked, unauthorized, removed, errs := s.enforceServer(&server, systemUser, masterKeyPEM, masterKeyPub, desiredKeys, mode)
|
||||
totalChecked += checked
|
||||
totalUnauthorized += unauthorized
|
||||
totalRemoved += removed
|
||||
totalErrors += errs
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary
|
||||
summary := fmt.Sprintf("Key enforcement run completed (mode=%s): %d servers checked, %d unauthorized keys found, %d removed, %d errors",
|
||||
mode, totalChecked, totalUnauthorized, totalRemoved, totalErrors)
|
||||
logging.Info("%s", summary)
|
||||
|
||||
if totalUnauthorized > 0 || totalErrors > 0 {
|
||||
s.audit.Log(0, audit.ActionEnforcementRun, summary, "worker")
|
||||
}
|
||||
|
||||
// Store last run info in settings
|
||||
s.setSetting("enforce_last_run", time.Now().UTC().Format(time.RFC3339))
|
||||
s.setSetting("enforce_last_result", summary)
|
||||
}
|
||||
|
||||
// buildDesiredState builds the complete desired-state map:
|
||||
//
|
||||
// server_id -> system_user -> []public_key
|
||||
//
|
||||
// Sources of truth (a key is "authorized" if it comes from any of these):
|
||||
// 1. Access Assignments with desired_state = "present"
|
||||
// 2. Active Cron Jobs (temporary access) whose keys haven't expired yet
|
||||
// 3. Direct deployments (via /deploy page) tracked in key_deployments
|
||||
// 4. The system master key (always authorized on every server+user)
|
||||
func (s *Service) buildDesiredState(masterKeyPub string) map[int64]map[string][]string {
|
||||
desired := make(map[int64]map[string][]string)
|
||||
|
||||
// Helper to add a key to the desired state (with deduplication)
|
||||
addKey := func(serverID int64, systemUser, pubKey string) {
|
||||
if serverID == 0 || systemUser == "" || pubKey == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := desired[serverID]; !ok {
|
||||
desired[serverID] = make(map[string][]string)
|
||||
}
|
||||
pubKey = strings.TrimSpace(pubKey)
|
||||
for _, existing := range desired[serverID][systemUser] {
|
||||
if existing == pubKey {
|
||||
return
|
||||
}
|
||||
}
|
||||
desired[serverID][systemUser] = append(desired[serverID][systemUser], pubKey)
|
||||
}
|
||||
|
||||
// --- Build key lookup: key_id -> public_key ---
|
||||
allKeys, err := s.keys.GetAllKeys()
|
||||
if err != nil {
|
||||
logging.Error("Key enforcement: failed to get all keys: %v", err)
|
||||
return desired
|
||||
}
|
||||
keyMap := make(map[int64]string)
|
||||
for _, k := range allKeys {
|
||||
keyMap[k.ID] = strings.TrimSpace(k.PublicKey)
|
||||
}
|
||||
|
||||
// --- Build server lookup: server_id -> Server ---
|
||||
allSrvs, _ := s.servers.GetAllServers()
|
||||
srvMap := make(map[int64]*models.Server)
|
||||
for i := range allSrvs {
|
||||
srvMap[allSrvs[i].ID] = &allSrvs[i]
|
||||
}
|
||||
|
||||
// --- 1) Access Assignments (desired_state = "present") ---
|
||||
assignments, err := s.servers.GetAllAssignments()
|
||||
if err != nil {
|
||||
logging.Error("Key enforcement: failed to get assignments: %v", err)
|
||||
} else {
|
||||
for _, a := range assignments {
|
||||
if a.DesiredState != "present" {
|
||||
continue
|
||||
}
|
||||
pubKey := keyMap[a.SSHKeyID]
|
||||
if pubKey == "" {
|
||||
continue
|
||||
}
|
||||
if a.ServerID > 0 {
|
||||
addKey(a.ServerID, a.SystemUser, pubKey)
|
||||
}
|
||||
if a.GroupID > 0 {
|
||||
members, err := s.servers.GetGroupMembersGlobal(a.GroupID)
|
||||
if err != nil {
|
||||
logging.Warn("Key enforcement: failed to resolve group %d: %v", a.GroupID, err)
|
||||
continue
|
||||
}
|
||||
for _, m := range members {
|
||||
addKey(m.ID, a.SystemUser, pubKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
logging.Debug("Key enforcement: loaded %d access assignments into desired state", len(assignments))
|
||||
}
|
||||
|
||||
// --- 2) Active Cron Jobs (temporary access, not yet expired) ---
|
||||
// A cron-deployed key is authorized if:
|
||||
// - remove_after_min = 0 (permanent) AND the job has executed (last_run IS NOT NULL)
|
||||
// - remove_after_min > 0 AND last_run + remove_after_min > NOW() (not yet expired)
|
||||
cronCount := s.addCronJobKeys(addKey, keyMap, srvMap)
|
||||
logging.Debug("Key enforcement: loaded %d active cron job deployments into desired state", cronCount)
|
||||
|
||||
// --- 3) Direct deployments (via /deploy page) ---
|
||||
// These are tracked in key_deployments. For each key+server pair, the latest
|
||||
// successful deploy (not removal) authorizes the key for the server's admin user.
|
||||
deployCount := s.addDirectDeployKeys(addKey, keyMap, srvMap)
|
||||
logging.Debug("Key enforcement: loaded %d direct deployments into desired state", deployCount)
|
||||
|
||||
// --- 4) System master key (always authorized everywhere) ---
|
||||
masterPub := strings.TrimSpace(masterKeyPub)
|
||||
for _, srv := range allSrvs {
|
||||
// Master key on every server's admin user
|
||||
addKey(srv.ID, srv.Username, masterPub)
|
||||
// Master key on every system user that has desired keys
|
||||
if users, ok := desired[srv.ID]; ok {
|
||||
for sysUser := range users {
|
||||
addKey(srv.ID, sysUser, masterPub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return desired
|
||||
}
|
||||
|
||||
// addCronJobKeys queries cron_jobs for active temporary deployments and adds
|
||||
// their keys to the desired state. Returns the number of active cron deployments found.
|
||||
func (s *Service) addCronJobKeys(addKey func(int64, string, string), keyMap map[int64]string, srvMap map[int64]*models.Server) int {
|
||||
// Query cron jobs whose deployed keys should still be on the server:
|
||||
// - Job has executed at least once (last_run IS NOT NULL)
|
||||
// - Either permanent (remove_after_min = 0) or not yet expired
|
||||
// - Job status indicates it has executed (not just created)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT cj.ssh_key_id, cj.server_id, cj.group_id, cj.system_user
|
||||
FROM cron_jobs cj
|
||||
WHERE cj.last_run IS NOT NULL
|
||||
AND cj.status IN ('done', 'active', 'running')
|
||||
AND (
|
||||
cj.remove_after_min = 0
|
||||
OR datetime(cj.last_run, '+' || cj.remove_after_min || ' minutes') > datetime('now')
|
||||
)`)
|
||||
if err != nil {
|
||||
logging.Warn("Key enforcement: failed to query active cron jobs: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var count int
|
||||
for rows.Next() {
|
||||
var keyID, serverID, groupID int64
|
||||
var systemUser string
|
||||
if err := rows.Scan(&keyID, &serverID, &groupID, &systemUser); err != nil {
|
||||
continue
|
||||
}
|
||||
pubKey := keyMap[keyID]
|
||||
if pubKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if serverID > 0 {
|
||||
if systemUser != "" {
|
||||
addKey(serverID, systemUser, pubKey)
|
||||
} else if srv, ok := srvMap[serverID]; ok {
|
||||
// No system user specified → deployed to server's admin user
|
||||
addKey(serverID, srv.Username, pubKey)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if groupID > 0 {
|
||||
members, err := s.servers.GetGroupMembersGlobal(groupID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range members {
|
||||
if systemUser != "" {
|
||||
addKey(m.ID, systemUser, pubKey)
|
||||
} else {
|
||||
addKey(m.ID, m.Username, pubKey)
|
||||
}
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// addDirectDeployKeys queries key_deployments for successful direct deployments
|
||||
// (via /deploy page) and adds their keys to the desired state.
|
||||
// For each key+server pair, the most recent entry determines if the key is still deployed.
|
||||
// Direct deploys always target the server's configured admin user.
|
||||
func (s *Service) addDirectDeployKeys(addKey func(int64, string, string), keyMap map[int64]string, srvMap map[int64]*models.Server) int {
|
||||
// Get the latest deployment status for each key+server combination.
|
||||
// A key is considered deployed if the latest entry contains "deployed" (not "removed").
|
||||
rows, err := s.db.Query(
|
||||
`SELECT kd.ssh_key_id, kd.server_id, kd.message
|
||||
FROM key_deployments kd
|
||||
INNER JOIN (
|
||||
SELECT ssh_key_id, server_id, MAX(id) as max_id
|
||||
FROM key_deployments
|
||||
WHERE status = 'success'
|
||||
GROUP BY ssh_key_id, server_id
|
||||
) latest ON kd.id = latest.max_id
|
||||
WHERE kd.message LIKE '%deployed%'`)
|
||||
if err != nil {
|
||||
logging.Warn("Key enforcement: failed to query direct deployments: %v", err)
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var count int
|
||||
for rows.Next() {
|
||||
var keyID, serverID int64
|
||||
var message string
|
||||
if err := rows.Scan(&keyID, &serverID, &message); err != nil {
|
||||
continue
|
||||
}
|
||||
pubKey := keyMap[keyID]
|
||||
if pubKey == "" {
|
||||
continue
|
||||
}
|
||||
srv, ok := srvMap[serverID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the system user from the deployment message
|
||||
// DeployKeyToUser logs: "key deployed to user 'xxx'"
|
||||
// DeployKey logs: "key deployed successfully" (→ server's admin user)
|
||||
systemUser := srv.Username
|
||||
if idx := strings.Index(message, "to user '"); idx >= 0 {
|
||||
rest := message[idx+len("to user '"):]
|
||||
if endIdx := strings.Index(rest, "'"); endIdx >= 0 {
|
||||
systemUser = rest[:endIdx]
|
||||
}
|
||||
}
|
||||
|
||||
addKey(serverID, systemUser, pubKey)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// getSystemUsersForServer returns all system users that should be checked on a server.
|
||||
// This includes users from:
|
||||
// 1. Access Assignments (direct + group)
|
||||
// 2. Active Cron Jobs (direct + group)
|
||||
func (s *Service) getSystemUsersForServer(serverID int64) map[string]bool {
|
||||
users := make(map[string]bool)
|
||||
|
||||
// --- 1a) Direct access assignments ---
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT system_user FROM access_assignments WHERE server_id = ? AND desired_state = 'present'`, serverID)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var u string
|
||||
if rows.Scan(&u) == nil && u != "" {
|
||||
users[u] = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
// --- 1b) Group access assignments ---
|
||||
groupRows, err := s.db.Query(
|
||||
`SELECT DISTINCT a.system_user FROM access_assignments a
|
||||
JOIN server_group_members sgm ON a.group_id = sgm.group_id
|
||||
WHERE sgm.server_id = ? AND a.desired_state = 'present' AND a.group_id > 0`, serverID)
|
||||
if err == nil {
|
||||
for groupRows.Next() {
|
||||
var u string
|
||||
if groupRows.Scan(&u) == nil && u != "" {
|
||||
users[u] = true
|
||||
}
|
||||
}
|
||||
groupRows.Close()
|
||||
}
|
||||
|
||||
// --- 2a) Direct cron jobs (active temporary access) ---
|
||||
cronRows, err := s.db.Query(
|
||||
`SELECT DISTINCT cj.system_user FROM cron_jobs cj
|
||||
WHERE cj.server_id = ?
|
||||
AND cj.last_run IS NOT NULL
|
||||
AND cj.status IN ('done', 'active', 'running')
|
||||
AND cj.system_user != ''
|
||||
AND (
|
||||
cj.remove_after_min = 0
|
||||
OR datetime(cj.last_run, '+' || cj.remove_after_min || ' minutes') > datetime('now')
|
||||
)`, serverID)
|
||||
if err == nil {
|
||||
for cronRows.Next() {
|
||||
var u string
|
||||
if cronRows.Scan(&u) == nil && u != "" {
|
||||
users[u] = true
|
||||
}
|
||||
}
|
||||
cronRows.Close()
|
||||
}
|
||||
|
||||
// --- 2b) Group cron jobs ---
|
||||
cronGroupRows, err := s.db.Query(
|
||||
`SELECT DISTINCT cj.system_user FROM cron_jobs cj
|
||||
JOIN server_group_members sgm ON cj.group_id = sgm.group_id
|
||||
WHERE sgm.server_id = ?
|
||||
AND cj.last_run IS NOT NULL
|
||||
AND cj.status IN ('done', 'active', 'running')
|
||||
AND cj.system_user != ''
|
||||
AND cj.group_id > 0
|
||||
AND (
|
||||
cj.remove_after_min = 0
|
||||
OR datetime(cj.last_run, '+' || cj.remove_after_min || ' minutes') > datetime('now')
|
||||
)`, serverID)
|
||||
if err == nil {
|
||||
for cronGroupRows.Next() {
|
||||
var u string
|
||||
if cronGroupRows.Scan(&u) == nil && u != "" {
|
||||
users[u] = true
|
||||
}
|
||||
}
|
||||
cronGroupRows.Close()
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
// enforceServer checks and optionally enforces key state for one server+user combination
|
||||
func (s *Service) enforceServer(server *models.Server, systemUser string, masterKeyPEM []byte, masterKeyPub string, desiredKeys map[int64]map[string][]string, mode string) (checked, unauthorized, removed, errors int) {
|
||||
checked = 1
|
||||
|
||||
// Read current authorized_keys from the server
|
||||
currentKeys, err := s.deploy.ReadAuthorizedKeys(server, masterKeyPEM, systemUser)
|
||||
if err != nil {
|
||||
logging.Warn("Key enforcement: failed to read keys from %s@%s:%d (user=%s): %v",
|
||||
server.Username, server.Hostname, server.Port, systemUser, err)
|
||||
errors = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Get desired keys for this server+user
|
||||
var desired []string
|
||||
if serverUsers, ok := desiredKeys[server.ID]; ok {
|
||||
if keys, ok := serverUsers[systemUser]; ok {
|
||||
desired = keys
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the master key
|
||||
masterPub := strings.TrimSpace(masterKeyPub)
|
||||
hasMaster := false
|
||||
for _, k := range desired {
|
||||
if k == masterPub {
|
||||
hasMaster = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMaster {
|
||||
desired = append(desired, masterPub)
|
||||
}
|
||||
|
||||
// Build set of desired key fingerprints/content for comparison
|
||||
desiredSet := make(map[string]bool)
|
||||
for _, k := range desired {
|
||||
desiredSet[normalizeKey(k)] = true
|
||||
}
|
||||
|
||||
// Find unauthorized keys
|
||||
var unauthorizedKeys []string
|
||||
for _, currentKey := range currentKeys {
|
||||
normalized := normalizeKey(currentKey)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if !desiredSet[normalized] {
|
||||
unauthorizedKeys = append(unauthorizedKeys, currentKey)
|
||||
}
|
||||
}
|
||||
|
||||
unauthorized = len(unauthorizedKeys)
|
||||
|
||||
if unauthorized == 0 {
|
||||
logging.Debug("Key enforcement: %s@%s (user=%s): all %d keys authorized",
|
||||
server.Username, server.Hostname, systemUser, len(currentKeys))
|
||||
return
|
||||
}
|
||||
|
||||
// Log the unauthorized keys
|
||||
keySnippets := make([]string, 0, len(unauthorizedKeys))
|
||||
for _, k := range unauthorizedKeys {
|
||||
snippet := k
|
||||
if len(snippet) > 80 {
|
||||
snippet = snippet[:80] + "..."
|
||||
}
|
||||
keySnippets = append(keySnippets, snippet)
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("Server %s (%s:%d), user '%s': %d unauthorized key(s) found: %s",
|
||||
server.Name, server.Hostname, server.Port, systemUser,
|
||||
unauthorized, strings.Join(keySnippets, "; "))
|
||||
|
||||
if mode == ModeMonitor {
|
||||
logging.Warn("Key enforcement [MONITOR]: %s", detail)
|
||||
s.audit.Log(0, audit.ActionEnforcementDrift, detail, "worker")
|
||||
return
|
||||
}
|
||||
|
||||
// Mode: enforce — replace authorized_keys with only desired keys
|
||||
logging.Warn("Key enforcement [ENFORCE]: %s — removing unauthorized keys", detail)
|
||||
s.audit.Log(0, audit.ActionEnforcementDrift, detail, "worker")
|
||||
|
||||
if err := s.deploy.WriteAuthorizedKeys(server, masterKeyPEM, systemUser, desired); err != nil {
|
||||
logging.Error("Key enforcement: failed to write authorized_keys for %s@%s (user=%s): %v",
|
||||
server.Username, server.Hostname, systemUser, err)
|
||||
s.audit.Log(0, audit.ActionEnforcementFailed,
|
||||
fmt.Sprintf("Failed to enforce keys on %s (%s:%d) user '%s': %v", server.Name, server.Hostname, server.Port, systemUser, err),
|
||||
"worker")
|
||||
errors = 1
|
||||
return
|
||||
}
|
||||
|
||||
removed = unauthorized
|
||||
s.audit.Log(0, audit.ActionEnforcementApplied,
|
||||
fmt.Sprintf("Enforced authorized_keys on %s (%s:%d) user '%s': removed %d unauthorized key(s)",
|
||||
server.Name, server.Hostname, server.Port, systemUser, removed),
|
||||
"worker")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// normalizeKey normalizes a public key line for comparison (strips comments and whitespace variations)
|
||||
func normalizeKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || strings.HasPrefix(key, "#") {
|
||||
return ""
|
||||
}
|
||||
// SSH public keys have format: type base64data [comment]
|
||||
// We compare type + base64data only (ignore the comment)
|
||||
parts := strings.Fields(key)
|
||||
if len(parts) >= 2 {
|
||||
return parts[0] + " " + parts[1]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// setSetting writes a value to the settings table (upsert)
|
||||
func (s *Service) setSetting(key, value string) {
|
||||
s.db.Exec(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||
key, value,
|
||||
)
|
||||
}
|
||||
|
||||
// GetStatus returns the current enforcement worker status for display
|
||||
func (s *Service) GetStatus() map[string]string {
|
||||
status := make(map[string]string)
|
||||
|
||||
var val string
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_mode'`).Scan(&val); err == nil {
|
||||
status["mode"] = val
|
||||
} else {
|
||||
status["mode"] = ModeDisabled
|
||||
}
|
||||
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_interval'`).Scan(&val); err == nil {
|
||||
status["interval"] = val
|
||||
} else {
|
||||
status["interval"] = fmt.Sprintf("%d", DefaultInterval)
|
||||
}
|
||||
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_last_run'`).Scan(&val); err == nil {
|
||||
status["last_run"] = val
|
||||
}
|
||||
|
||||
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'enforce_last_result'`).Scan(&val); err == nil {
|
||||
status["last_result"] = val
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
181
tools/subset-icons.py
Normal file
181
tools/subset-icons.py
Normal 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()
|
||||
4
tools/tabler-icons-full/tabler-icons.min.css
vendored
Normal file
4
tools/tabler-icons-full/tabler-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
tools/tabler-icons-full/tabler-icons.woff2
Normal file
BIN
tools/tabler-icons-full/tabler-icons.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
web/static/css/tabler-icons.min.css
vendored
2
web/static/css/tabler-icons.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -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">
|
||||
@@ -226,7 +269,7 @@
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="toggleMasterKey()" title="Show/Hide">
|
||||
<i class="ti ti-eye" id="masterKeyEyeIcon"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('masterKeyValue').value); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy">
|
||||
<button class="btn btn-outline-primary" type="button" onclick="copyMasterKey(this)" title="Copy">
|
||||
<i class="ti ti-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -236,6 +279,26 @@
|
||||
<code>{{.MasterKeyFingerprint}}</code>
|
||||
</div>
|
||||
<script>
|
||||
function copyMasterKey(btn) {
|
||||
var text = document.getElementById('masterKeyValue').value;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.innerHTML = '<i class="ti ti-check"></i>';
|
||||
setTimeout(function() { btn.innerHTML = '<i class="ti ti-copy"></i>'; }, 2000);
|
||||
});
|
||||
} else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="ti ti-check"></i>';
|
||||
setTimeout(function() { btn.innerHTML = '<i class="ti ti-copy"></i>'; }, 2000);
|
||||
}
|
||||
}
|
||||
function toggleMasterKey() {
|
||||
var input = document.getElementById('masterKeyDisplay');
|
||||
var icon = document.getElementById('masterKeyEyeIcon');
|
||||
@@ -284,6 +347,87 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Enforcement (Bastillion-Style) -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-shield-check"></i> Key Enforcement</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<div class="d-flex">
|
||||
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
|
||||
<div>
|
||||
<h4 class="alert-title">Enforced Key Management</h4>
|
||||
<div class="text-secondary">
|
||||
When enabled, Keywarden periodically connects to all managed servers and verifies that only
|
||||
authorized SSH keys (managed by Keywarden + the system master key) are present in
|
||||
<code>authorized_keys</code>. Unauthorized keys are detected and optionally removed automatically.
|
||||
<br><br>
|
||||
<strong>Monitor mode:</strong> Detects unauthorized keys and logs them in the audit log, but does not remove them.<br>
|
||||
<strong>Enforce mode:</strong> Detects unauthorized keys and <em>removes them automatically</em>, keeping only Keywarden-managed keys.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/admin/settings" method="post">
|
||||
<input type="hidden" name="form_type" value="enforcement_settings">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Enforcement Mode</label>
|
||||
<select name="enforce_mode" class="form-select">
|
||||
<option value="disabled" {{if or (not .EnforcementStatus) (eq (index .EnforcementStatus "mode") "disabled")}}selected{{end}}>Disabled</option>
|
||||
<option value="monitor" {{if and .EnforcementStatus (eq (index .EnforcementStatus "mode") "monitor")}}selected{{end}}>Monitor (detect only)</option>
|
||||
<option value="enforce" {{if and .EnforcementStatus (eq (index .EnforcementStatus "mode") "enforce")}}selected{{end}}>Enforce (detect & remove)</option>
|
||||
</select>
|
||||
<small class="form-hint">Choose how Keywarden handles unauthorized keys on your servers.</small>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Check Interval (minutes)</label>
|
||||
<input type="number" name="enforce_interval" class="form-control"
|
||||
value="{{if and .EnforcementStatus (index .EnforcementStatus "interval")}}{{index .EnforcementStatus "interval"}}{{else}}15{{end}}"
|
||||
min="1" max="1440" placeholder="15">
|
||||
<small class="form-hint">How often Keywarden checks the servers (1–1440 minutes).</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="ti ti-device-floppy"></i> Save Enforcement Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if and .EnforcementStatus (index .EnforcementStatus "last_run")}}
|
||||
<hr class="my-4">
|
||||
<h4 class="mb-3"><i class="ti ti-history"></i> Last Enforcement Run</h4>
|
||||
<div class="datagrid mb-3">
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Last Run</div>
|
||||
<div class="datagrid-content">{{index .EnforcementStatus "last_run"}}</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Result</div>
|
||||
<div class="datagrid-content">{{index .EnforcementStatus "last_result"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and .EnforcementStatus (ne (index .EnforcementStatus "mode") "disabled")}}
|
||||
<hr class="my-4">
|
||||
<h4 class="mb-3"><i class="ti ti-player-play"></i> Manual Run</h4>
|
||||
<form action="/admin/enforcement/run" method="post" onsubmit="return confirm('Start a key enforcement run now? This will connect to all managed servers.');">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="ti ti-player-play"></i> Run Enforcement Now
|
||||
</button>
|
||||
<small class="form-hint d-inline-block ms-2">Trigger an immediate enforcement check on all servers.</small>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
© 2026 Keywarden | AGPLv3
|
||||
<div class="text-center text-secondary login-footer mt-3">
|
||||
© 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
</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">
|
||||
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
||||
<i class="ti ti-eye"></i>
|
||||
</a>
|
||||
</button>
|
||||
{{if eq .UserID $.User.ID}}
|
||||
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
||||
<i class="ti ti-download"></i>
|
||||
@@ -89,12 +89,12 @@
|
||||
</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">
|
||||
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
|
||||
<button type="button" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key" onclick="showPublicKey({{.ID}}, '{{.Name}}')">
|
||||
<i class="ti ti-eye"></i>
|
||||
</a>
|
||||
</button>
|
||||
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
||||
<i class="ti ti-download"></i>
|
||||
</a>
|
||||
@@ -121,4 +121,104 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Key Modal -->
|
||||
<div class="modal modal-blur fade" id="publicKeyModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="ti ti-key text-primary"></i> Public Key: <span id="publicKeyName"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="publicKeyLoading" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="mt-2 text-secondary">Loading public key...</p>
|
||||
</div>
|
||||
<div id="publicKeyContent" class="d-none">
|
||||
<textarea id="publicKeyText" class="form-control" rows="6" readonly style="font-family: monospace; font-size: 0.85rem;"></textarea>
|
||||
</div>
|
||||
<div id="publicKeyError" class="d-none">
|
||||
<div class="alert alert-danger mb-0">
|
||||
<i class="ti ti-alert-triangle"></i> Failed to load public key.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="copyPublicKeyBtn" onclick="copyPublicKey()">
|
||||
<i class="ti ti-copy"></i> Copy to Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showPublicKey(keyID, keyName) {
|
||||
document.getElementById('publicKeyName').textContent = keyName;
|
||||
document.getElementById('publicKeyLoading').classList.remove('d-none');
|
||||
document.getElementById('publicKeyContent').classList.add('d-none');
|
||||
document.getElementById('publicKeyError').classList.add('d-none');
|
||||
document.getElementById('copyPublicKeyBtn').classList.remove('d-none');
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('publicKeyModal'));
|
||||
modal.show();
|
||||
|
||||
fetch('/keys/' + keyID + '/view')
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('Failed to load key');
|
||||
return response.text();
|
||||
})
|
||||
.then(function(pubKey) {
|
||||
document.getElementById('publicKeyText').value = pubKey;
|
||||
document.getElementById('publicKeyLoading').classList.add('d-none');
|
||||
document.getElementById('publicKeyContent').classList.remove('d-none');
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('publicKeyLoading').classList.add('d-none');
|
||||
document.getElementById('publicKeyError').classList.remove('d-none');
|
||||
document.getElementById('copyPublicKeyBtn').classList.add('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
function copyPublicKey() {
|
||||
var textarea = document.getElementById('publicKeyText');
|
||||
var text = textarea.value;
|
||||
var success = false;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(onCopySuccess).catch(fallbackCopy);
|
||||
return;
|
||||
}
|
||||
fallbackCopy();
|
||||
|
||||
function fallbackCopy() {
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, 99999);
|
||||
try { success = document.execCommand('copy'); } catch(e) { success = false; }
|
||||
if (success) { onCopySuccess(); } else { alert('Copy failed. Please select the key manually and copy it.'); }
|
||||
}
|
||||
|
||||
function onCopySuccess() {
|
||||
var btn = document.getElementById('copyPublicKeyBtn');
|
||||
btn.innerHTML = '<i class="ti ti-check"></i> Copied!';
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(function() {
|
||||
btn.innerHTML = '<i class="ti ti-copy"></i> Copy to Clipboard';
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Move modal to body so it is not clipped by overflow containers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var modal = document.getElementById('publicKeyModal');
|
||||
if (modal) {
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -8,25 +8,35 @@
|
||||
<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() {
|
||||
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'auto';
|
||||
var resolved = theme;
|
||||
if (theme === 'auto') {
|
||||
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'ocean-auto';
|
||||
// Map legacy default values to ocean
|
||||
if (theme === 'auto' || theme === 'light' || theme === 'dark') {
|
||||
theme = 'ocean-' + theme;
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
window.__kwThemeRaw = theme;
|
||||
var pair = 'default', mode = theme;
|
||||
if (theme !== 'auto' && theme !== 'light' && theme !== 'dark') {
|
||||
var idx = theme.lastIndexOf('-');
|
||||
if (idx > 0) { pair = theme.substring(0, idx); mode = theme.substring(idx + 1); }
|
||||
}
|
||||
if (mode !== 'light' && mode !== 'dark') {
|
||||
mode = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-bs-theme', mode);
|
||||
document.documentElement.style.colorScheme = mode;
|
||||
if (pair !== 'default') document.documentElement.setAttribute('data-theme-pair', pair);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* Critical inline styles: prevent white flash between page navigations */
|
||||
html[data-bs-theme="dark"],
|
||||
html[data-bs-theme="dark"] body { background-color: #0F1829; color-scheme: dark; }
|
||||
html[data-bs-theme="dark"] body { background-color: #0c1a2a; color-scheme: dark; }
|
||||
html[data-bs-theme="light"],
|
||||
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
|
||||
html[data-bs-theme="light"] body { background-color: #ecfeff; color-scheme: light; }
|
||||
.navbar-brand-image { height: 2rem; }
|
||||
.keywarden-brand { font-weight: 700; font-size: 1.25rem; color: #206bc4; }
|
||||
[data-bs-theme="dark"] .keywarden-brand { color: #4da3ff; }
|
||||
@@ -53,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; }
|
||||
@@ -109,15 +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: 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;
|
||||
@@ -135,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 {
|
||||
@@ -144,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; }
|
||||
@@ -322,6 +356,355 @@
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════ */
|
||||
/* ADDITIONAL THEME PAIRS */
|
||||
/* ═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Shared themed overrides (active when any theme pair is set) */
|
||||
html[data-theme-pair] .btn-primary {
|
||||
--tblr-btn-bg: var(--kw-primary);
|
||||
--tblr-btn-border-color: var(--kw-primary);
|
||||
--tblr-btn-hover-bg: var(--kw-primary-hover);
|
||||
--tblr-btn-hover-border-color: var(--kw-primary-hover);
|
||||
--tblr-btn-active-bg: var(--kw-primary-active);
|
||||
--tblr-btn-active-border-color: var(--kw-primary-active);
|
||||
}
|
||||
html[data-theme-pair] .btn-outline-primary {
|
||||
--tblr-btn-color: var(--kw-primary);
|
||||
--tblr-btn-border-color: var(--kw-primary);
|
||||
--tblr-btn-hover-bg: var(--kw-primary);
|
||||
--tblr-btn-hover-border-color: var(--kw-primary);
|
||||
--tblr-btn-active-bg: var(--kw-primary-hover);
|
||||
--tblr-btn-active-border-color: var(--kw-primary-hover);
|
||||
}
|
||||
html[data-theme-pair] {
|
||||
--tblr-link-color: var(--kw-primary);
|
||||
--tblr-link-hover-color: var(--kw-primary-hover);
|
||||
}
|
||||
html[data-theme-pair] .bg-primary-lt {
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||
}
|
||||
html[data-theme-pair] .alert-primary {
|
||||
--tblr-alert-color: var(--kw-primary);
|
||||
--tblr-alert-bg: rgba(var(--tblr-primary-rgb), 0.07);
|
||||
--tblr-alert-border-color: rgba(var(--tblr-primary-rgb), 0.15);
|
||||
}
|
||||
html[data-theme-pair] .form-check-input:checked {
|
||||
background-color: var(--kw-primary);
|
||||
border-color: var(--kw-primary);
|
||||
}
|
||||
html[data-theme-pair] .form-select:focus,
|
||||
html[data-theme-pair] .form-control:focus {
|
||||
border-color: var(--kw-primary);
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--tblr-primary-rgb), 0.25);
|
||||
}
|
||||
html[data-theme-pair] .nav-tabs .nav-link.active {
|
||||
border-bottom-color: var(--kw-primary);
|
||||
}
|
||||
/* Override Tabler hardcoded blue badges to use theme accent */
|
||||
html[data-theme-pair] .bg-blue-lt {
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
html[data-theme-pair] .bg-azure-lt {
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
html[data-theme-pair] .bg-cyan-lt {
|
||||
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
html[data-theme-pair] .text-primary {
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
html[data-theme-pair] .text-blue {
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
html[data-theme-pair] .text-azure {
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
html[data-theme-pair] .text-cyan {
|
||||
color: var(--kw-primary) !important;
|
||||
}
|
||||
|
||||
/* ── Ocean Theme ── */
|
||||
html[data-theme-pair="ocean"] {
|
||||
--kw-primary: #0891b2; --kw-primary-hover: #0e7490; --kw-primary-active: #155e75;
|
||||
--tblr-primary: #0891b2; --tblr-primary-rgb: 8,145,178;
|
||||
}
|
||||
html[data-theme-pair="ocean"][data-bs-theme="dark"] {
|
||||
--kw-primary: #06b6d4; --kw-primary-hover: #0891b2; --kw-primary-active: #0e7490;
|
||||
--tblr-primary: #06b6d4; --tblr-primary-rgb: 6,182,212;
|
||||
--tblr-bg-surface: #0f2035; --tblr-bg-surface-secondary: #122840;
|
||||
--tblr-bg-surface-tertiary: #0c1e30; --tblr-bg-surface-dark: #0c1a2a;
|
||||
--tblr-bg-forms: #0c1e30; --tblr-body-bg: #0c1a2a; --tblr-body-bg-rgb: 12,26,42;
|
||||
--tblr-border-color: #1a3555; --tblr-border-color-translucent: rgba(6, 182, 212, 0.12);
|
||||
}
|
||||
html[data-theme-pair="ocean"][data-bs-theme="light"],
|
||||
html[data-theme-pair="ocean"][data-bs-theme="light"] body { background-color: #ecfeff !important; }
|
||||
html[data-theme-pair="ocean"][data-bs-theme="dark"],
|
||||
html[data-theme-pair="ocean"][data-bs-theme="dark"] body { background-color: #0c1a2a !important; }
|
||||
html[data-theme-pair="ocean"][data-bs-theme="light"] .navbar-vertical,
|
||||
html[data-theme-pair="ocean"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: 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: 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; }
|
||||
html[data-theme-pair="ocean"][data-bs-theme="light"] ::-moz-selection { background: #a5f3fc; color: #155e75; }
|
||||
html[data-theme-pair="ocean"][data-bs-theme="dark"] ::selection { background: #164e63; color: #ecfeff; }
|
||||
html[data-theme-pair="ocean"][data-bs-theme="dark"] ::-moz-selection { background: #164e63; color: #ecfeff; }
|
||||
|
||||
/* ── Forest Theme ── */
|
||||
html[data-theme-pair="forest"] {
|
||||
--kw-primary: #16a34a; --kw-primary-hover: #15803d; --kw-primary-active: #166534;
|
||||
--tblr-primary: #16a34a; --tblr-primary-rgb: 22,163,74;
|
||||
}
|
||||
html[data-theme-pair="forest"][data-bs-theme="dark"] {
|
||||
--kw-primary: #4ade80; --kw-primary-hover: #22c55e; --kw-primary-active: #16a34a;
|
||||
--tblr-primary: #4ade80; --tblr-primary-rgb: 74,222,128;
|
||||
--tblr-bg-surface: #0f2216; --tblr-bg-surface-secondary: #122a1b;
|
||||
--tblr-bg-surface-tertiary: #0c1d12; --tblr-bg-surface-dark: #0a1a10;
|
||||
--tblr-bg-forms: #0c1d12; --tblr-body-bg: #0a1a10; --tblr-body-bg-rgb: 10,26,16;
|
||||
--tblr-border-color: #1a3524; --tblr-border-color-translucent: rgba(74, 222, 128, 0.10);
|
||||
}
|
||||
html[data-theme-pair="forest"][data-bs-theme="light"],
|
||||
html[data-theme-pair="forest"][data-bs-theme="light"] body { background-color: #f0fdf4 !important; }
|
||||
html[data-theme-pair="forest"][data-bs-theme="dark"],
|
||||
html[data-theme-pair="forest"][data-bs-theme="dark"] body { background-color: #0a1a10 !important; }
|
||||
html[data-theme-pair="forest"][data-bs-theme="light"] .navbar-vertical,
|
||||
html[data-theme-pair="forest"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: 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: 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; }
|
||||
html[data-theme-pair="forest"][data-bs-theme="light"] ::-moz-selection { background: #bbf7d0; color: #14532d; }
|
||||
html[data-theme-pair="forest"][data-bs-theme="dark"] ::selection { background: #166534; color: #f0fdf4; }
|
||||
html[data-theme-pair="forest"][data-bs-theme="dark"] ::-moz-selection { background: #166534; color: #f0fdf4; }
|
||||
|
||||
/* ── Sunset Theme ── */
|
||||
html[data-theme-pair="sunset"] {
|
||||
--kw-primary: #d97706; --kw-primary-hover: #b45309; --kw-primary-active: #92400e;
|
||||
--tblr-primary: #d97706; --tblr-primary-rgb: 217,119,6;
|
||||
}
|
||||
html[data-theme-pair="sunset"][data-bs-theme="dark"] {
|
||||
--kw-primary: #f59e0b; --kw-primary-hover: #d97706; --kw-primary-active: #b45309;
|
||||
--tblr-primary: #f59e0b; --tblr-primary-rgb: 245,158,11;
|
||||
--tblr-bg-surface: #221a0e; --tblr-bg-surface-secondary: #281f12;
|
||||
--tblr-bg-surface-tertiary: #1e170a; --tblr-bg-surface-dark: #1a1408;
|
||||
--tblr-bg-forms: #1e170a; --tblr-body-bg: #1a1408; --tblr-body-bg-rgb: 26,20,8;
|
||||
--tblr-border-color: #3a2c14; --tblr-border-color-translucent: rgba(245, 158, 11, 0.10);
|
||||
}
|
||||
html[data-theme-pair="sunset"][data-bs-theme="light"],
|
||||
html[data-theme-pair="sunset"][data-bs-theme="light"] body { background-color: #fffbeb !important; }
|
||||
html[data-theme-pair="sunset"][data-bs-theme="dark"],
|
||||
html[data-theme-pair="sunset"][data-bs-theme="dark"] body { background-color: #1a1408 !important; }
|
||||
html[data-theme-pair="sunset"][data-bs-theme="light"] .navbar-vertical,
|
||||
html[data-theme-pair="sunset"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: 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: 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; }
|
||||
html[data-theme-pair="sunset"][data-bs-theme="light"] ::-moz-selection { background: #fde68a; color: #78350f; }
|
||||
html[data-theme-pair="sunset"][data-bs-theme="dark"] ::selection { background: #92400e; color: #fffbeb; }
|
||||
html[data-theme-pair="sunset"][data-bs-theme="dark"] ::-moz-selection { background: #92400e; color: #fffbeb; }
|
||||
|
||||
/* ── Rose Theme ── */
|
||||
html[data-theme-pair="rose"] {
|
||||
--kw-primary: #db2777; --kw-primary-hover: #be185d; --kw-primary-active: #9d174d;
|
||||
--tblr-primary: #db2777; --tblr-primary-rgb: 219,39,119;
|
||||
}
|
||||
html[data-theme-pair="rose"][data-bs-theme="dark"] {
|
||||
--kw-primary: #f472b6; --kw-primary-hover: #ec4899; --kw-primary-active: #db2777;
|
||||
--tblr-primary: #f472b6; --tblr-primary-rgb: 244,114,182;
|
||||
--tblr-bg-surface: #22101a; --tblr-bg-surface-secondary: #281420;
|
||||
--tblr-bg-surface-tertiary: #1e0c16; --tblr-bg-surface-dark: #1a0a14;
|
||||
--tblr-bg-forms: #1e0c16; --tblr-body-bg: #1a0a14; --tblr-body-bg-rgb: 26,10,20;
|
||||
--tblr-border-color: #3a1a2c; --tblr-border-color-translucent: rgba(244, 114, 182, 0.10);
|
||||
}
|
||||
html[data-theme-pair="rose"][data-bs-theme="light"],
|
||||
html[data-theme-pair="rose"][data-bs-theme="light"] body { background-color: #fdf2f8 !important; }
|
||||
html[data-theme-pair="rose"][data-bs-theme="dark"],
|
||||
html[data-theme-pair="rose"][data-bs-theme="dark"] body { background-color: #1a0a14 !important; }
|
||||
html[data-theme-pair="rose"][data-bs-theme="light"] .navbar-vertical,
|
||||
html[data-theme-pair="rose"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: 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: 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; }
|
||||
html[data-theme-pair="rose"][data-bs-theme="light"] ::-moz-selection { background: #fbcfe8; color: #831843; }
|
||||
html[data-theme-pair="rose"][data-bs-theme="dark"] ::selection { background: #9f1239; color: #fdf2f8; }
|
||||
html[data-theme-pair="rose"][data-bs-theme="dark"] ::-moz-selection { background: #9f1239; color: #fdf2f8; }
|
||||
|
||||
/* ── Nord Theme ── */
|
||||
html[data-theme-pair="nord"] {
|
||||
--kw-primary: #5e81ac; --kw-primary-hover: #4c6e96; --kw-primary-active: #3b5b80;
|
||||
--tblr-primary: #5e81ac; --tblr-primary-rgb: 94,129,172;
|
||||
}
|
||||
html[data-theme-pair="nord"][data-bs-theme="dark"] {
|
||||
--kw-primary: #88c0d0; --kw-primary-hover: #6eb0c2; --kw-primary-active: #5e9fb4;
|
||||
--tblr-primary: #88c0d0; --tblr-primary-rgb: 136,192,208;
|
||||
--tblr-bg-surface: #242830; --tblr-bg-surface-secondary: #2a2e36;
|
||||
--tblr-bg-surface-tertiary: #21252c; --tblr-bg-surface-dark: #1e2128;
|
||||
--tblr-bg-forms: #21252c; --tblr-body-bg: #1e2128; --tblr-body-bg-rgb: 30,33,40;
|
||||
--tblr-border-color: #353a44; --tblr-border-color-translucent: rgba(136, 192, 208, 0.10);
|
||||
}
|
||||
html[data-theme-pair="nord"][data-bs-theme="light"],
|
||||
html[data-theme-pair="nord"][data-bs-theme="light"] body { background-color: #eceff4 !important; }
|
||||
html[data-theme-pair="nord"][data-bs-theme="dark"],
|
||||
html[data-theme-pair="nord"][data-bs-theme="dark"] body { background-color: #1e2128 !important; }
|
||||
html[data-theme-pair="nord"][data-bs-theme="light"] .navbar-vertical,
|
||||
html[data-theme-pair="nord"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: 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: 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">
|
||||
@@ -344,6 +727,15 @@
|
||||
</div>
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow-1"></div>
|
||||
<!-- Update Available Badge (Admin/Owner only) -->
|
||||
{{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}}
|
||||
<div class="nav-item d-none d-md-flex me-2">
|
||||
<a href="{{releaseURL}}" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Update verfügbar: {{latestVersion}} (aktuell: {{appVersion}})">
|
||||
<i class="ti ti-download" style="color: #fbbf24;"></i>
|
||||
<span style="color: #fbbf24;">Update {{latestVersion}}</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
<!-- Repository Link -->
|
||||
<div class="nav-item d-none d-md-flex me-2">
|
||||
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
|
||||
@@ -546,7 +938,7 @@
|
||||
<div class="container-xl">
|
||||
<div class="row text-center align-items-center">
|
||||
<div class="col-12">
|
||||
<span class="text-secondary">© 2026 Keywarden – Centralized SSH Key Management and Deployment | AGPLv3</span>
|
||||
<span class="text-secondary">© 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>
|
||||
@@ -558,14 +950,36 @@
|
||||
<script src="/static/js/tabler.min.js"></script>
|
||||
<script>
|
||||
// --- Theme Toggle ---
|
||||
function getResolvedTheme() {
|
||||
var stored = document.documentElement.getAttribute('data-bs-theme');
|
||||
return stored || 'light';
|
||||
function parseTheme(theme) {
|
||||
if (!theme || theme === 'auto' || theme === 'light' || theme === 'dark') {
|
||||
return { pair: 'default', mode: theme || 'auto' };
|
||||
}
|
||||
var idx = theme.lastIndexOf('-');
|
||||
if (idx > 0) {
|
||||
return { pair: theme.substring(0, idx), mode: theme.substring(idx + 1) };
|
||||
}
|
||||
return { pair: 'default', mode: 'auto' };
|
||||
}
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
updateThemeIcon(theme);
|
||||
function resolveMode(mode) {
|
||||
if (mode !== 'light' && mode !== 'dark') {
|
||||
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
function getResolvedTheme() {
|
||||
return document.documentElement.getAttribute('data-bs-theme') || 'light';
|
||||
}
|
||||
function applyFullTheme(themeStr) {
|
||||
var parts = parseTheme(themeStr);
|
||||
var resolved = resolveMode(parts.mode);
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
if (parts.pair !== 'default') {
|
||||
document.documentElement.setAttribute('data-theme-pair', parts.pair);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme-pair');
|
||||
}
|
||||
updateThemeIcon(resolved);
|
||||
}
|
||||
function updateThemeIcon(theme) {
|
||||
var icon = document.getElementById('theme-icon');
|
||||
@@ -573,15 +987,18 @@
|
||||
icon.className = theme === 'dark' ? 'ti ti-moon' : 'ti ti-sun';
|
||||
}
|
||||
function toggleTheme() {
|
||||
var current = getResolvedTheme();
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
// Persist choice via API (fire-and-forget)
|
||||
var raw = window.__kwThemeRaw || 'auto';
|
||||
var parts = parseTheme(raw);
|
||||
var currentMode = getResolvedTheme();
|
||||
var nextMode = currentMode === 'dark' ? 'light' : 'dark';
|
||||
var nextTheme = parts.pair === 'default' ? nextMode : parts.pair + '-' + nextMode;
|
||||
window.__kwThemeRaw = nextTheme;
|
||||
applyFullTheme(nextTheme);
|
||||
var csrf = (document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/) || [])[1] || '';
|
||||
fetch('/settings/theme', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'theme=' + encodeURIComponent(next) + '&_csrf=' + encodeURIComponent(csrf)
|
||||
body: 'theme=' + encodeURIComponent(nextTheme) + '&_csrf=' + encodeURIComponent(csrf)
|
||||
});
|
||||
}
|
||||
// Set initial icon on page load
|
||||
|
||||
@@ -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">
|
||||
© 2026 Keywarden | AGPLv3
|
||||
<div class="text-center text-secondary login-footer mt-3">
|
||||
© 2026 <a href="https://keywarden.app" target="_blank" rel="noopener noreferrer" class="text-secondary text-decoration-none">Keywarden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -11,10 +11,32 @@
|
||||
<div class="row align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Theme</label>
|
||||
<select name="theme" class="form-select" style="width: 250px;">
|
||||
<option value="auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto")}}selected{{end}}>Automatic (System)</option>
|
||||
<option value="light" {{if and .User (eq .User.Theme "light")}}selected{{end}}>Light</option>
|
||||
<option value="dark" {{if and .User (eq .User.Theme "dark")}}selected{{end}}>Dark</option>
|
||||
<select name="theme" class="form-select" style="width: 280px;">
|
||||
<optgroup label="🌊 Ocean (Standard)">
|
||||
<option value="ocean-auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto") (eq .User.Theme "ocean-auto")}}selected{{end}}>Ocean (System)</option>
|
||||
<option value="ocean-light" {{if or (and .User (eq .User.Theme "ocean-light")) (and .User (eq .User.Theme "light"))}}selected{{end}}>Ocean Light</option>
|
||||
<option value="ocean-dark" {{if or (and .User (eq .User.Theme "ocean-dark")) (and .User (eq .User.Theme "dark"))}}selected{{end}}>Ocean Dark</option>
|
||||
</optgroup>
|
||||
<optgroup label="🌲 Forest">
|
||||
<option value="forest-auto" {{if and .User (eq .User.Theme "forest-auto")}}selected{{end}}>Forest (System)</option>
|
||||
<option value="forest-light" {{if and .User (eq .User.Theme "forest-light")}}selected{{end}}>Forest Light</option>
|
||||
<option value="forest-dark" {{if and .User (eq .User.Theme "forest-dark")}}selected{{end}}>Forest Dark</option>
|
||||
</optgroup>
|
||||
<optgroup label="🌅 Sunset">
|
||||
<option value="sunset-auto" {{if and .User (eq .User.Theme "sunset-auto")}}selected{{end}}>Sunset (System)</option>
|
||||
<option value="sunset-light" {{if and .User (eq .User.Theme "sunset-light")}}selected{{end}}>Sunset Light</option>
|
||||
<option value="sunset-dark" {{if and .User (eq .User.Theme "sunset-dark")}}selected{{end}}>Sunset Dark</option>
|
||||
</optgroup>
|
||||
<optgroup label="🌹 Rose">
|
||||
<option value="rose-auto" {{if and .User (eq .User.Theme "rose-auto")}}selected{{end}}>Rose (System)</option>
|
||||
<option value="rose-light" {{if and .User (eq .User.Theme "rose-light")}}selected{{end}}>Rose Light</option>
|
||||
<option value="rose-dark" {{if and .User (eq .User.Theme "rose-dark")}}selected{{end}}>Rose Dark</option>
|
||||
</optgroup>
|
||||
<optgroup label="❄️ Nord">
|
||||
<option value="nord-auto" {{if and .User (eq .User.Theme "nord-auto")}}selected{{end}}>Nord (System)</option>
|
||||
<option value="nord-light" {{if and .User (eq .User.Theme "nord-light")}}selected{{end}}>Nord Light</option>
|
||||
<option value="nord-dark" {{if and .User (eq .User.Theme "nord-dark")}}selected{{end}}>Nord Dark</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
<div class="card-body">
|
||||
{{with .SystemInfo}}
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Keywarden Version</div>
|
||||
<div class="datagrid-content">
|
||||
<span class="badge bg-blue-lt">{{appVersion}}</span>
|
||||
{{if updateAvailable}}
|
||||
<a href="{{releaseURL}}" target="_blank" rel="noopener noreferrer" class="badge bg-yellow-lt ms-1" title="Update verfügbar">
|
||||
<i class="ti ti-download"></i> {{latestVersion}} verfügbar
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Runtime Environment</div>
|
||||
<div class="datagrid-content">
|
||||
@@ -55,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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -78,11 +78,13 @@
|
||||
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||
<i class="ti ti-edit"></i>
|
||||
</a>
|
||||
{{if ne .ID $.InitialOwnerID}}
|
||||
<form method="POST" action="/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this user?')">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||
<i class="ti ti-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label required">Role</label>
|
||||
{{if .IsInitialOwner}}
|
||||
<select name="role" class="form-select" disabled>
|
||||
<option value="owner" selected>Owner</option>
|
||||
</select>
|
||||
<input type="hidden" name="role" value="owner">
|
||||
<small class="form-hint text-warning"><i class="ti ti-shield-lock"></i> The initial owner role cannot be changed. This account was created during installation and is permanently protected.</small>
|
||||
{{else}}
|
||||
<select name="role" class="form-select">
|
||||
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
|
||||
{{with $.User}}
|
||||
@@ -47,6 +54,7 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">MFA Status</label>
|
||||
@@ -71,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>
|
||||
|
||||
Reference in New Issue
Block a user