Merge pull request 'v0.3.0-alpha' (#4) from v0.3.0-alpha into master
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 6m44s
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 6m44s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Lint, Build & Test
|
name: Lint, Build & Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26-alpine
|
image: golang:1.26.2-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Go Vulnerability Check
|
name: Go Vulnerability Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26-alpine
|
image: golang:1.26.2-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Multi-stage build for minimal image size
|
# Multi-stage build for minimal image size
|
||||||
|
|
||||||
# Stage 1: Build
|
# 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
|
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X main.Version=${VERSION}" ./cmd/keywarden/
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
**Keywarden** is a self-hosted web application for centralized SSH key management and deployment. It lets you generate, store, and deploy SSH keys to Linux servers from a single web interface — with full audit logging, role-based access control, and automated temporary access scheduling.
|
**Keywarden** 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
|
## ⚠️ Alpha Software — Important Notice
|
||||||
|
|
||||||
@@ -23,9 +25,11 @@
|
|||||||
- **Temporary Access** — Schedule time-limited access with automatic expiry (key removal, user disable, or user deletion)
|
- **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
|
- **Three-Tier Roles** — Owner, Admin, and User with distinct permissions
|
||||||
- **User Invitations** — Invite users via secure email links
|
- **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
|
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
|
||||||
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
|
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
|
||||||
- **Audit Log** — Every action tracked with user, IP, timestamp, and details
|
- **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
|
- **Encrypted Backup/Restore** — Full database export with password-based encryption
|
||||||
- **Docker-Native** — Single container with embedded SQLite, no external database required
|
- **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: 53 KiB |
@@ -24,9 +24,18 @@ import (
|
|||||||
"git.techniverse.net/scriptos/keywarden/internal/mail"
|
"git.techniverse.net/scriptos/keywarden/internal/mail"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||||
|
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||||
|
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||||
"git.techniverse.net/scriptos/keywarden/web"
|
"git.techniverse.net/scriptos/keywarden/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Version is set at build time via -ldflags:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.Version=v1.0.0" ./cmd/keywarden/
|
||||||
|
//
|
||||||
|
// When building with Docker, pass --build-arg VERSION=v1.0.0
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Handle CLI subcommands before starting the server
|
// Handle CLI subcommands before starting the server
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
@@ -46,7 +55,7 @@ func main() {
|
|||||||
// Initialize structured logging
|
// Initialize structured logging
|
||||||
logging.Init(cfg.LogLevel)
|
logging.Init(cfg.LogLevel)
|
||||||
|
|
||||||
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
|
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", Version)
|
||||||
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
||||||
|
|
||||||
// Validate data paths – relative paths inside a container bypass the
|
// Validate data paths – relative paths inside a container bypass the
|
||||||
@@ -76,6 +85,7 @@ func main() {
|
|||||||
deploySvc := deploy.NewService(db)
|
deploySvc := deploy.NewService(db)
|
||||||
auditSvc := audit.NewService(db)
|
auditSvc := audit.NewService(db)
|
||||||
cronSvc := cron.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc)
|
cronSvc := cron.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc)
|
||||||
|
workerSvc := worker.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc)
|
||||||
mailSvc := mail.NewService(cfg)
|
mailSvc := mail.NewService(cfg)
|
||||||
|
|
||||||
// Create default owner if no users exist (password is auto-generated)
|
// Create default owner if no users exist (password is auto-generated)
|
||||||
@@ -115,8 +125,11 @@ func main() {
|
|||||||
logging.Info("Base URL: %s", cfg.BaseURL)
|
logging.Info("Base URL: %s", cfg.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize update checker
|
||||||
|
updaterSvc := updater.NewService(Version)
|
||||||
|
|
||||||
// Setup HTTP handlers
|
// 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()
|
mux := http.NewServeMux()
|
||||||
handler.RegisterRoutes(mux)
|
handler.RegisterRoutes(mux)
|
||||||
@@ -142,6 +155,14 @@ func main() {
|
|||||||
cronSvc.Start()
|
cronSvc.Start()
|
||||||
defer cronSvc.Stop()
|
defer cronSvc.Stop()
|
||||||
|
|
||||||
|
// Start key enforcement worker
|
||||||
|
workerSvc.Start()
|
||||||
|
defer workerSvc.Stop()
|
||||||
|
|
||||||
|
// Start update checker
|
||||||
|
updaterSvc.Start()
|
||||||
|
defer updaterSvc.Stop()
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
logging.Info("Server starting on http://0.0.0.0%s", addr)
|
logging.Info("Server starting on http://0.0.0.0%s", addr)
|
||||||
@@ -272,7 +293,7 @@ func handleResetPassword(args []string) {
|
|||||||
|
|
||||||
// printUsage displays available CLI subcommands
|
// printUsage displays available CLI subcommands
|
||||||
func printUsage() {
|
func printUsage() {
|
||||||
fmt.Println("Keywarden - Centralized SSH Key Management and Deployment")
|
fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", Version)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Usage:")
|
fmt.Println("Usage:")
|
||||||
fmt.Println(" keywarden Start the server")
|
fmt.Println(" keywarden Start the server")
|
||||||
|
|||||||
@@ -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
|
- **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
|
- **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
|
- **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
|
- **TOTP Two-Factor Authentication** — Optional or enforced MFA for all users
|
||||||
- **Password Policies** — Configurable complexity requirements with account lockout
|
- **Password Policies** — Configurable complexity requirements with account lockout
|
||||||
- **Email Notifications** — Login alerts and invitation emails via SMTP
|
- **Email Notifications** — Login alerts and invitation emails via SMTP
|
||||||
|
|||||||
@@ -209,12 +209,19 @@ Deleting a user removes their SSH keys, server records, and all related data (CA
|
|||||||
|
|
||||||
Navigate to **System** to view runtime information:
|
Navigate to **System** to view runtime information:
|
||||||
|
|
||||||
|
- Application version (with update badge if a newer release is available)
|
||||||
- Go version, OS, architecture
|
- Go version, OS, architecture
|
||||||
- CPU count, goroutine count
|
- CPU count, goroutine count
|
||||||
- Memory allocation
|
- Memory allocation
|
||||||
- Runtime environment (Docker or native)
|
- Runtime environment (Docker or native)
|
||||||
- Hostname and uptime
|
- Hostname and uptime
|
||||||
|
|
||||||
|
## Update Notifications
|
||||||
|
|
||||||
|
Keywarden automatically checks for new releases in the background by querying the Gitea releases API. If a newer version is available, a yellow update badge is displayed in the top header for **Admin** and **Owner** users. The badge links directly to the release page on Gitea.
|
||||||
|
|
||||||
|
The update checker is only active when the application was built with a version tag (via `--build-arg VERSION=...`). Development builds (`dev`) skip the check entirely.
|
||||||
|
|
||||||
## Admin Settings (Owner Only)
|
## Admin Settings (Owner Only)
|
||||||
|
|
||||||
See [Roles & Permissions](roles.md) for details on which settings are owner-only.
|
See [Roles & Permissions](roles.md) for details on which settings are owner-only.
|
||||||
@@ -234,6 +241,14 @@ Navigate to **Admin Settings** (owner only) to configure:
|
|||||||
- **Account Lockout** — Number of failed attempts before lockout and lockout duration
|
- **Account Lockout** — Number of failed attempts before lockout and lockout duration
|
||||||
- **MFA Enforcement** — Require all users to enable TOTP MFA
|
- **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
|
### Master Key
|
||||||
|
|
||||||
- View the system master key's public key and fingerprint
|
- View the system master key's public key and fingerprint
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ internal/
|
|||||||
security/ ← CSRF, security headers, rate limiting, proxy detection
|
security/ ← CSRF, security headers, rate limiting, proxy detection
|
||||||
servers/ ← Server and server group management, access assignments
|
servers/ ← Server and server group management, access assignments
|
||||||
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
|
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
|
||||||
|
updater/ ← Background update checker (Gitea releases API)
|
||||||
|
worker/ ← Background key enforcement worker (Bastillion-style)
|
||||||
web/
|
web/
|
||||||
embed.go ← Go embed directives for templates and static files
|
embed.go ← Go embed directives for templates and static files
|
||||||
static/ ← CSS, JS, fonts (Tabler UI framework)
|
static/ ← CSS, JS, fonts (Tabler UI framework)
|
||||||
@@ -59,7 +61,8 @@ web/
|
|||||||
10. **Start session cleanup** goroutine (removes expired sessions every minute)
|
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
|
||||||
12. **Start cron scheduler** (checks for pending jobs every 30 seconds)
|
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
|
## Database Design
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ go mod download
|
|||||||
# Build
|
# Build
|
||||||
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
|
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
|
||||||
|
|
||||||
|
# Build with version (optional, enables update checker)
|
||||||
|
CGO_ENABLED=1 go build -ldflags="-X 'main.Version=v1.0.0'" -o keywarden ./cmd/keywarden/
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
./keywarden
|
./keywarden
|
||||||
```
|
```
|
||||||
@@ -82,7 +85,8 @@ keywarden/
|
|||||||
│ │ ├── ratelimit.go # IP-based rate limiting middleware
|
│ │ ├── ratelimit.go # IP-based rate limiting middleware
|
||||||
│ │ └── sizelimit.go # Request body size limit middleware
|
│ │ └── sizelimit.go # Request body size limit middleware
|
||||||
│ ├── servers/servers.go # Server and group management, access assignments
|
│ ├── 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/
|
├── web/
|
||||||
│ ├── embed.go # Go embed directives
|
│ ├── embed.go # Go embed directives
|
||||||
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ docker compose build
|
|||||||
|
|
||||||
# Or build manually
|
# Or build manually
|
||||||
docker build -t keywarden .
|
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
|
### 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 runtime container runs as a non-root user (`keywarden`).
|
||||||
|
|
||||||
|
The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. This enables the built-in update checker to compare the running version against the latest Gitea release. If omitted, the version defaults to `dev` and the update checker is disabled.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
A complete `docker-compose.yml`:
|
A complete `docker-compose.yml`:
|
||||||
|
|||||||
@@ -98,11 +98,14 @@ The **Owner** role has unrestricted access. In addition to all Admin permissions
|
|||||||
|
|
||||||
#### Owner Protections
|
#### 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 last owner account cannot be deleted
|
||||||
- The owner can always access Admin Settings, even when MFA enforcement would otherwise redirect them (to prevent lockout)
|
- 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
|
- 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
|
- 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
|
## Audit Log Visibility
|
||||||
|
|
||||||
The audit log has role-based filtering:
|
The audit log has role-based filtering:
|
||||||
|
|||||||
@@ -209,3 +209,58 @@ When deploying keys to servers, Keywarden:
|
|||||||
8. **Network isolation**: Restrict access to Keywarden and managed servers to trusted networks
|
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
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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:
|
From the key list, you can:
|
||||||
- **Public Key** — For deployment to servers
|
- **View Public Key** — Opens a modal overlay showing the public key with a copy-to-clipboard button
|
||||||
- **Private Key** — Decrypted and downloaded (use with caution)
|
- **Download Private Key** — Decrypted and downloaded (use with caution)
|
||||||
|
|
||||||
### Deleting Keys
|
### Deleting Keys
|
||||||
|
|
||||||
@@ -88,11 +88,23 @@ Navigate to **Settings** to manage your account:
|
|||||||
|
|
||||||
### Theme
|
### Theme
|
||||||
|
|
||||||
Choose between:
|
KeyWarden offers five color themes, each available in three modes:
|
||||||
- **Auto** — Follows your system/browser preference
|
|
||||||
|
| 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
|
- **Light** — Always light mode
|
||||||
- **Dark** — Always dark mode
|
- **Dark** — Always dark mode
|
||||||
|
|
||||||
|
> Existing installations using the previous theme values (`auto`, `light`, `dark`) are automatically migrated to the Ocean theme.
|
||||||
|
|
||||||
### Password Change
|
### Password Change
|
||||||
|
|
||||||
Change your password. The new password must comply with the configured password policy (displayed on the form).
|
Change your password. The new password must comply with the configured password policy (displayed on the form).
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module git.techniverse.net/scriptos/keywarden
|
module git.techniverse.net/scriptos/keywarden
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cloudflare/circl v1.6.3
|
github.com/cloudflare/circl v1.6.3
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ const (
|
|||||||
ActionInvitationSendFailed = "invitation_send_failed"
|
ActionInvitationSendFailed = "invitation_send_failed"
|
||||||
ActionInvitationAccepted = "invitation_accepted"
|
ActionInvitationAccepted = "invitation_accepted"
|
||||||
ActionInvitationFailed = "invitation_failed"
|
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
|
// AuditEntry extends AuditLog with the username for display
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
|
|||||||
return false, "", fmt.Errorf("failed to hash password: %w", err)
|
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)`,
|
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`,
|
||||||
username, email, string(hash), "owner",
|
username, email, string(hash), "owner",
|
||||||
)
|
)
|
||||||
@@ -248,6 +248,11 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
|
|||||||
return false, "", err
|
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.
|
// Mark initial setup as complete so the password is never regenerated.
|
||||||
s.markInitialSetupComplete()
|
s.markInitialSetupComplete()
|
||||||
|
|
||||||
@@ -262,6 +267,43 @@ func (s *Service) isInitialSetupComplete() bool {
|
|||||||
return err == nil && val == "true"
|
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.
|
// markInitialSetupComplete persists the initial-setup flag in the settings table.
|
||||||
func (s *Service) markInitialSetupComplete() {
|
func (s *Service) markInitialSetupComplete() {
|
||||||
s.db.Exec(
|
s.db.Exec(
|
||||||
@@ -359,10 +401,26 @@ func (s *Service) DisableMFA(userID int64) error {
|
|||||||
return err
|
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 {
|
func (s *Service) UpdateTheme(id int64, theme string) error {
|
||||||
if theme != "auto" && theme != "light" && theme != "dark" {
|
// Map legacy default values to ocean
|
||||||
theme = "auto"
|
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(
|
_, err := s.db.Exec(
|
||||||
`UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
`UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
|||||||
@@ -246,5 +246,25 @@ func (d *DB) migrate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: backfill initial_owner_id for existing installations
|
||||||
|
{
|
||||||
|
var migCount int
|
||||||
|
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'backfill_initial_owner_id'`).Scan(&migCount)
|
||||||
|
if migCount == 0 {
|
||||||
|
// Only set if not already present (new installs set it in EnsureAdmin)
|
||||||
|
var existing string
|
||||||
|
err := d.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&existing)
|
||||||
|
if err != nil || existing == "" {
|
||||||
|
// Pick the oldest owner (lowest ID) as the initial owner
|
||||||
|
var ownerID int64
|
||||||
|
err := d.QueryRow(`SELECT id FROM users WHERE role = 'owner' ORDER BY id ASC LIMIT 1`).Scan(&ownerID)
|
||||||
|
if err == nil && ownerID > 0 {
|
||||||
|
d.Exec(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', CAST(? AS TEXT), CURRENT_TIMESTAMP)`, ownerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_initial_owner_id')`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -655,3 +655,110 @@ func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error)
|
|||||||
}
|
}
|
||||||
return deployments, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import (
|
|||||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
"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
|
// sessionData holds session metadata for timeout tracking
|
||||||
@@ -56,7 +58,9 @@ type Handler struct {
|
|||||||
deploy *deploy.Service
|
deploy *deploy.Service
|
||||||
audit *audit.Service
|
audit *audit.Service
|
||||||
cron *cron.Service
|
cron *cron.Service
|
||||||
|
worker *worker.Service
|
||||||
mail *mail.Service
|
mail *mail.Service
|
||||||
|
updater *updater.Service
|
||||||
db *database.DB // direct database access for backup/restore
|
db *database.DB // direct database access for backup/restore
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
sessions map[string]*sessionData // cookie -> session data with timeout tracking
|
sessions map[string]*sessionData // cookie -> session data with timeout tracking
|
||||||
@@ -169,6 +173,13 @@ type PageData struct {
|
|||||||
|
|
||||||
// System Information
|
// System Information
|
||||||
SystemInfo *SystemInfo
|
SystemInfo *SystemInfo
|
||||||
|
|
||||||
|
// Key Enforcement
|
||||||
|
EnforcementStatus map[string]string
|
||||||
|
|
||||||
|
// Initial Owner protection
|
||||||
|
IsInitialOwner bool
|
||||||
|
InitialOwnerID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemInfo holds runtime system information for the settings page
|
// SystemInfo holds runtime system information for the settings page
|
||||||
@@ -242,7 +253,7 @@ func formatUptime(start time.Time) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handler
|
// 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
|
// Create sub-FS so /static/css/... maps to static/css/... in embed
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -262,7 +273,9 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
|
|||||||
deploy: deploySvc,
|
deploy: deploySvc,
|
||||||
audit: auditSvc,
|
audit: auditSvc,
|
||||||
cron: cronSvc,
|
cron: cronSvc,
|
||||||
|
worker: workerSvc,
|
||||||
mail: mailSvc,
|
mail: mailSvc,
|
||||||
|
updater: updaterSvc,
|
||||||
db: db,
|
db: db,
|
||||||
sessions: make(map[string]*sessionData),
|
sessions: make(map[string]*sessionData),
|
||||||
pending: make(map[string]int64),
|
pending: make(map[string]int64),
|
||||||
@@ -292,6 +305,18 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
|||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
},
|
},
|
||||||
|
"appVersion": func() string {
|
||||||
|
return h.updater.CurrentVersion()
|
||||||
|
},
|
||||||
|
"updateAvailable": func() bool {
|
||||||
|
return h.updater.HasUpdate()
|
||||||
|
},
|
||||||
|
"latestVersion": func() string {
|
||||||
|
return h.updater.LatestVersion()
|
||||||
|
},
|
||||||
|
"releaseURL": func() string {
|
||||||
|
return h.updater.ReleaseURL()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
||||||
@@ -432,6 +457,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/admin/masterkey/regenerate", h.requireOwner(h.handleMasterKeyRegenerate))
|
mux.HandleFunc("/admin/masterkey/regenerate", h.requireOwner(h.handleMasterKeyRegenerate))
|
||||||
mux.HandleFunc("/admin/backup/export", h.requireOwner(h.handleBackupExport))
|
mux.HandleFunc("/admin/backup/export", h.requireOwner(h.handleBackupExport))
|
||||||
mux.HandleFunc("/admin/backup/import", h.requireOwner(h.handleBackupImport))
|
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).
|
// handleAPIHealth returns a JSON health status (no auth required).
|
||||||
@@ -646,6 +672,11 @@ func isOwner(role string) bool {
|
|||||||
return role == "owner"
|
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 {
|
func (h *Handler) getUserID(r *http.Request) int64 {
|
||||||
id, _ := strconv.ParseInt(r.Header.Get("X-User-ID"), 10, 64)
|
id, _ := strconv.ParseInt(r.Header.Get("X-User-ID"), 10, 64)
|
||||||
return id
|
return id
|
||||||
@@ -1819,10 +1850,11 @@ func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := &PageData{
|
data := &PageData{
|
||||||
Title: "User Management",
|
Title: "User Management",
|
||||||
Active: "users",
|
Active: "users",
|
||||||
User: user,
|
User: user,
|
||||||
Users: users,
|
Users: users,
|
||||||
|
InitialOwnerID: h.getInitialOwnerID(),
|
||||||
}
|
}
|
||||||
h.templates["users"].ExecuteTemplate(w, "base", data)
|
h.templates["users"].ExecuteTemplate(w, "base", data)
|
||||||
}
|
}
|
||||||
@@ -1995,6 +2027,7 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
User: user,
|
User: user,
|
||||||
EditUser: targetUser,
|
EditUser: targetUser,
|
||||||
PasswordPolicy: &policy,
|
PasswordPolicy: &policy,
|
||||||
|
IsInitialOwner: h.auth.IsInitialOwner(targetID),
|
||||||
}
|
}
|
||||||
h.templates["users_edit"].ExecuteTemplate(w, "base", data)
|
h.templates["users_edit"].ExecuteTemplate(w, "base", data)
|
||||||
return
|
return
|
||||||
@@ -2007,6 +2040,22 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
newPassword := r.FormValue("password")
|
newPassword := r.FormValue("password")
|
||||||
forceChange := r.FormValue("must_change_password") == "1"
|
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:
|
// Enforce role restrictions:
|
||||||
// - Admin can only assign "user" role
|
// - Admin can only assign "user" role
|
||||||
// - Only owner can assign "admin" or "owner"
|
// - Only owner can assign "admin" or "owner"
|
||||||
@@ -2100,6 +2149,11 @@ func (h *Handler) handleUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
if r.Method == http.MethodPost {
|
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
|
// Owner protection: cannot self-delete
|
||||||
if targetID == userID {
|
if targetID == userID {
|
||||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||||
@@ -2993,6 +3047,7 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
EmailEnabled: h.mail.IsEnabled(),
|
EmailEnabled: h.mail.IsEnabled(),
|
||||||
MasterKeyPublic: masterPub,
|
MasterKeyPublic: masterPub,
|
||||||
MasterKeyFingerprint: masterFP,
|
MasterKeyFingerprint: masterFP,
|
||||||
|
EnforcementStatus: h.worker.GetStatus(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for flash message from query parameters (e.g. after backup restore)
|
// Check for flash message from query parameters (e.g. after backup restore)
|
||||||
@@ -3047,6 +3102,31 @@ func (h *Handler) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(changed) > 0 {
|
if len(changed) > 0 {
|
||||||
h.audit.Log(userID, audit.ActionPasswordPolicyChanged, fmt.Sprintf("Security settings updated: %s", strings.Join(changed, ", ")), clientIP(r))
|
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:
|
default:
|
||||||
// Application settings (existing behavior)
|
// Application settings (existing behavior)
|
||||||
batch := make(map[string]string)
|
batch := make(map[string]string)
|
||||||
@@ -3124,6 +3204,22 @@ func (h *Handler) handleMasterKeyRegenerate(w http.ResponseWriter, r *http.Reque
|
|||||||
http.Redirect(w, r, "/admin/settings?flash_type=success&flash_msg="+url.QueryEscape("System master key successfully regenerated."), http.StatusSeeOther)
|
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 ---
|
// --- Cron Job Handlers ---
|
||||||
|
|
||||||
// handleAPICronAssignments returns assignments for a given user as JSON (for AJAX).
|
// handleAPICronAssignments returns assignments for a given user as JSON (for AJAX).
|
||||||
|
|||||||
193
internal/updater/updater.go
Normal file
193
internal/updater/updater.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// Keywarden - Centralized SSH Key Management and Deployment
|
||||||
|
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Gitea API endpoint for releases
|
||||||
|
releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5"
|
||||||
|
// How often to check for updates
|
||||||
|
checkInterval = 6 * time.Hour
|
||||||
|
// HTTP timeout for API requests
|
||||||
|
httpTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// giteaRelease represents the relevant fields from the Gitea releases API
|
||||||
|
type giteaRelease struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service checks for new releases in the background
|
||||||
|
type Service struct {
|
||||||
|
currentVersion string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
latestVersion string
|
||||||
|
releaseURL string
|
||||||
|
hasUpdate bool
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates an update checker. Pass the current application version
|
||||||
|
// (e.g. "v1.0.0" or "dev"). The checker runs in the background and queries
|
||||||
|
// the Gitea releases API periodically.
|
||||||
|
func NewService(currentVersion string) *Service {
|
||||||
|
return &Service{
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins periodic update checks in the background.
|
||||||
|
func (s *Service) Start() {
|
||||||
|
// Don't check if running a dev build
|
||||||
|
if s.currentVersion == "" || s.currentVersion == "dev" {
|
||||||
|
logging.Info("Update checker disabled (development build)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.Info("Update checker started (current version: %s, checking every %s)", s.currentVersion, checkInterval)
|
||||||
|
|
||||||
|
go s.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop signals the background goroutine to exit.
|
||||||
|
func (s *Service) Stop() {
|
||||||
|
close(s.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUpdate returns true if a newer version is available.
|
||||||
|
func (s *Service) HasUpdate() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.hasUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestVersion returns the tag name of the latest release (e.g. "v1.2.0").
|
||||||
|
func (s *Service) LatestVersion() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.latestVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseURL returns the HTML link to the latest release page on Gitea.
|
||||||
|
func (s *Service) ReleaseURL() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.releaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentVersion returns the running application version.
|
||||||
|
func (s *Service) CurrentVersion() string {
|
||||||
|
return s.currentVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) run() {
|
||||||
|
// Initial check shortly after startup
|
||||||
|
timer := time.NewTimer(30 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
s.check()
|
||||||
|
timer.Reset(checkInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) check() {
|
||||||
|
client := &http.Client{Timeout: httpTimeout}
|
||||||
|
|
||||||
|
resp, err := client.Get(releasesAPI)
|
||||||
|
if err != nil {
|
||||||
|
logging.Warn("Update check failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logging.Warn("Update check: Gitea API returned status %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var releases []giteaRelease
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
|
logging.Warn("Update check: failed to parse response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the latest stable release (not draft, not prerelease)
|
||||||
|
for _, rel := range releases {
|
||||||
|
if rel.Draft || rel.Prerelease || rel.TagName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.latestVersion = rel.TagName
|
||||||
|
s.releaseURL = rel.HTMLURL
|
||||||
|
s.hasUpdate = isNewer(rel.TagName, s.currentVersion)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.HasUpdate() {
|
||||||
|
logging.Info("New version available: %s (current: %s)", rel.TagName, s.currentVersion)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewer returns true if latest is a higher version than current.
|
||||||
|
// Both may optionally have a "v" prefix (e.g. "v1.2.3").
|
||||||
|
func isNewer(latest, current string) bool {
|
||||||
|
latestParts := parseVersion(latest)
|
||||||
|
currentParts := parseVersion(current)
|
||||||
|
|
||||||
|
for i := 0; i < len(latestParts) || i < len(currentParts); i++ {
|
||||||
|
l, c := 0, 0
|
||||||
|
if i < len(latestParts) {
|
||||||
|
l = latestParts[i]
|
||||||
|
}
|
||||||
|
if i < len(currentParts) {
|
||||||
|
c = currentParts[i]
|
||||||
|
}
|
||||||
|
if l > c {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if l < c {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVersion strips the "v" prefix and splits "1.2.3" into [1, 2, 3].
|
||||||
|
func parseVersion(v string) []int {
|
||||||
|
v = strings.TrimPrefix(v, "v")
|
||||||
|
parts := strings.Split(v, ".")
|
||||||
|
nums := make([]int, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
return nums
|
||||||
|
}
|
||||||
672
internal/worker/worker.go
Normal file
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
|
||||||
|
}
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
<button class="btn btn-outline-secondary" type="button" onclick="toggleMasterKey()" title="Show/Hide">
|
<button class="btn btn-outline-secondary" type="button" onclick="toggleMasterKey()" title="Show/Hide">
|
||||||
<i class="ti ti-eye" id="masterKeyEyeIcon"></i>
|
<i class="ti ti-eye" id="masterKeyEyeIcon"></i>
|
||||||
</button>
|
</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>
|
<i class="ti ti-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,6 +236,26 @@
|
|||||||
<code>{{.MasterKeyFingerprint}}</code>
|
<code>{{.MasterKeyFingerprint}}</code>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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() {
|
function toggleMasterKey() {
|
||||||
var input = document.getElementById('masterKeyDisplay');
|
var input = document.getElementById('masterKeyDisplay');
|
||||||
var icon = document.getElementById('masterKeyEyeIcon');
|
var icon = document.getElementById('masterKeyEyeIcon');
|
||||||
@@ -284,6 +304,87 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Backup & Restore -->
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -50,9 +50,9 @@
|
|||||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<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>
|
<i class="ti ti-eye"></i>
|
||||||
</a>
|
</button>
|
||||||
{{if eq .UserID $.User.ID}}
|
{{if eq .UserID $.User.ID}}
|
||||||
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
||||||
<i class="ti ti-download"></i>
|
<i class="ti ti-download"></i>
|
||||||
@@ -92,9 +92,9 @@
|
|||||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<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>
|
<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">
|
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
|
||||||
<i class="ti ti-download"></i>
|
<i class="ti ti-download"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -121,4 +121,104 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}}
|
{{end}}
|
||||||
|
|||||||
@@ -12,21 +12,31 @@
|
|||||||
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'auto';
|
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'ocean-auto';
|
||||||
var resolved = theme;
|
// Map legacy default values to ocean
|
||||||
if (theme === 'auto') {
|
if (theme === 'auto' || theme === 'light' || theme === 'dark') {
|
||||||
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
theme = 'ocean-' + theme;
|
||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
window.__kwThemeRaw = theme;
|
||||||
document.documentElement.style.colorScheme = resolved;
|
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>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
/* Critical inline styles: prevent white flash between page navigations */
|
/* Critical inline styles: prevent white flash between page navigations */
|
||||||
html[data-bs-theme="dark"],
|
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"],
|
||||||
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; }
|
.navbar-brand-image { height: 2rem; }
|
||||||
.keywarden-brand { font-weight: 700; font-size: 1.25rem; color: #206bc4; }
|
.keywarden-brand { font-weight: 700; font-size: 1.25rem; color: #206bc4; }
|
||||||
[data-bs-theme="dark"] .keywarden-brand { color: #4da3ff; }
|
[data-bs-theme="dark"] .keywarden-brand { color: #4da3ff; }
|
||||||
@@ -118,6 +128,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
[data-bs-theme="dark"] .navbar-vertical { background: #0a1120 !important; }
|
||||||
|
[data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #0a1120 !important; border-bottom-color: rgba(255,255,255,0.04); }
|
||||||
.navbar-vertical > .container-fluid {
|
.navbar-vertical > .container-fluid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -322,6 +334,232 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════ */
|
||||||
|
/* ADDITIONAL THEME PAIRS */
|
||||||
|
/* ═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Shared themed overrides (active when any theme pair is set) */
|
||||||
|
html[data-theme-pair] .btn-primary {
|
||||||
|
--tblr-btn-bg: var(--kw-primary);
|
||||||
|
--tblr-btn-border-color: var(--kw-primary);
|
||||||
|
--tblr-btn-hover-bg: var(--kw-primary-hover);
|
||||||
|
--tblr-btn-hover-border-color: var(--kw-primary-hover);
|
||||||
|
--tblr-btn-active-bg: var(--kw-primary-active);
|
||||||
|
--tblr-btn-active-border-color: var(--kw-primary-active);
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .btn-outline-primary {
|
||||||
|
--tblr-btn-color: var(--kw-primary);
|
||||||
|
--tblr-btn-border-color: var(--kw-primary);
|
||||||
|
--tblr-btn-hover-bg: var(--kw-primary);
|
||||||
|
--tblr-btn-hover-border-color: var(--kw-primary);
|
||||||
|
--tblr-btn-active-bg: var(--kw-primary-hover);
|
||||||
|
--tblr-btn-active-border-color: var(--kw-primary-hover);
|
||||||
|
}
|
||||||
|
html[data-theme-pair] {
|
||||||
|
--tblr-link-color: var(--kw-primary);
|
||||||
|
--tblr-link-hover-color: var(--kw-primary-hover);
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .bg-primary-lt {
|
||||||
|
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .alert-primary {
|
||||||
|
--tblr-alert-color: var(--kw-primary);
|
||||||
|
--tblr-alert-bg: rgba(var(--tblr-primary-rgb), 0.07);
|
||||||
|
--tblr-alert-border-color: rgba(var(--tblr-primary-rgb), 0.15);
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .form-check-input:checked {
|
||||||
|
background-color: var(--kw-primary);
|
||||||
|
border-color: var(--kw-primary);
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .form-select:focus,
|
||||||
|
html[data-theme-pair] .form-control:focus {
|
||||||
|
border-color: var(--kw-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(var(--tblr-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .nav-tabs .nav-link.active {
|
||||||
|
border-bottom-color: var(--kw-primary);
|
||||||
|
}
|
||||||
|
/* Override Tabler hardcoded blue badges to use theme accent */
|
||||||
|
html[data-theme-pair] .bg-blue-lt {
|
||||||
|
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .bg-azure-lt {
|
||||||
|
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .bg-cyan-lt {
|
||||||
|
background-color: rgba(var(--tblr-primary-rgb), 0.1) !important;
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .text-primary {
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .text-blue {
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .text-azure {
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
html[data-theme-pair] .text-cyan {
|
||||||
|
color: var(--kw-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ocean Theme ── */
|
||||||
|
html[data-theme-pair="ocean"] {
|
||||||
|
--kw-primary: #0891b2; --kw-primary-hover: #0e7490; --kw-primary-active: #155e75;
|
||||||
|
--tblr-primary: #0891b2; --tblr-primary-rgb: 8,145,178;
|
||||||
|
}
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] {
|
||||||
|
--kw-primary: #06b6d4; --kw-primary-hover: #0891b2; --kw-primary-active: #0e7490;
|
||||||
|
--tblr-primary: #06b6d4; --tblr-primary-rgb: 6,182,212;
|
||||||
|
--tblr-bg-surface: #0f2035; --tblr-bg-surface-secondary: #122840;
|
||||||
|
--tblr-bg-surface-tertiary: #0c1e30; --tblr-bg-surface-dark: #0c1a2a;
|
||||||
|
--tblr-bg-forms: #0c1e30; --tblr-body-bg: #0c1a2a; --tblr-body-bg-rgb: 12,26,42;
|
||||||
|
--tblr-border-color: #1a3555; --tblr-border-color-translucent: rgba(6, 182, 212, 0.12);
|
||||||
|
}
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"],
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"] body { background-color: #ecfeff !important; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"],
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] body { background-color: #0c1a2a !important; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #155e75 !important; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #071220 !important; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"] .page-wrapper { background: #ecfeff; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] .page-wrapper,
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] .page-body { background: #0c1a2a; }
|
||||||
|
html[data-theme-pair="ocean"] .keywarden-header-brand .keywarden-brand i.ti { color: #22d3ee; }
|
||||||
|
html[data-theme-pair="ocean"] .nav-category { color: rgba(160, 220, 230, 0.6); }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"] ::selection { background: #a5f3fc; color: #155e75; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="light"] ::-moz-selection { background: #a5f3fc; color: #155e75; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] ::selection { background: #164e63; color: #ecfeff; }
|
||||||
|
html[data-theme-pair="ocean"][data-bs-theme="dark"] ::-moz-selection { background: #164e63; color: #ecfeff; }
|
||||||
|
|
||||||
|
/* ── Forest Theme ── */
|
||||||
|
html[data-theme-pair="forest"] {
|
||||||
|
--kw-primary: #16a34a; --kw-primary-hover: #15803d; --kw-primary-active: #166534;
|
||||||
|
--tblr-primary: #16a34a; --tblr-primary-rgb: 22,163,74;
|
||||||
|
}
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] {
|
||||||
|
--kw-primary: #4ade80; --kw-primary-hover: #22c55e; --kw-primary-active: #16a34a;
|
||||||
|
--tblr-primary: #4ade80; --tblr-primary-rgb: 74,222,128;
|
||||||
|
--tblr-bg-surface: #0f2216; --tblr-bg-surface-secondary: #122a1b;
|
||||||
|
--tblr-bg-surface-tertiary: #0c1d12; --tblr-bg-surface-dark: #0a1a10;
|
||||||
|
--tblr-bg-forms: #0c1d12; --tblr-body-bg: #0a1a10; --tblr-body-bg-rgb: 10,26,16;
|
||||||
|
--tblr-border-color: #1a3524; --tblr-border-color-translucent: rgba(74, 222, 128, 0.10);
|
||||||
|
}
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"],
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"] body { background-color: #f0fdf4 !important; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"],
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] body { background-color: #0a1a10 !important; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #14532d !important; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #061209 !important; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"] .page-wrapper { background: #f0fdf4; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] .page-wrapper,
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] .page-body { background: #0a1a10; }
|
||||||
|
html[data-theme-pair="forest"] .keywarden-header-brand .keywarden-brand i.ti { color: #4ade80; }
|
||||||
|
html[data-theme-pair="forest"] .nav-category { color: rgba(160, 210, 170, 0.6); }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"] ::selection { background: #bbf7d0; color: #14532d; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="light"] ::-moz-selection { background: #bbf7d0; color: #14532d; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] ::selection { background: #166534; color: #f0fdf4; }
|
||||||
|
html[data-theme-pair="forest"][data-bs-theme="dark"] ::-moz-selection { background: #166534; color: #f0fdf4; }
|
||||||
|
|
||||||
|
/* ── Sunset Theme ── */
|
||||||
|
html[data-theme-pair="sunset"] {
|
||||||
|
--kw-primary: #d97706; --kw-primary-hover: #b45309; --kw-primary-active: #92400e;
|
||||||
|
--tblr-primary: #d97706; --tblr-primary-rgb: 217,119,6;
|
||||||
|
}
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] {
|
||||||
|
--kw-primary: #f59e0b; --kw-primary-hover: #d97706; --kw-primary-active: #b45309;
|
||||||
|
--tblr-primary: #f59e0b; --tblr-primary-rgb: 245,158,11;
|
||||||
|
--tblr-bg-surface: #221a0e; --tblr-bg-surface-secondary: #281f12;
|
||||||
|
--tblr-bg-surface-tertiary: #1e170a; --tblr-bg-surface-dark: #1a1408;
|
||||||
|
--tblr-bg-forms: #1e170a; --tblr-body-bg: #1a1408; --tblr-body-bg-rgb: 26,20,8;
|
||||||
|
--tblr-border-color: #3a2c14; --tblr-border-color-translucent: rgba(245, 158, 11, 0.10);
|
||||||
|
}
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"],
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"] body { background-color: #fffbeb !important; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"],
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] body { background-color: #1a1408 !important; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #78350f !important; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #110d04 !important; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"] .page-wrapper { background: #fffbeb; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] .page-wrapper,
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] .page-body { background: #1a1408; }
|
||||||
|
html[data-theme-pair="sunset"] .keywarden-header-brand .keywarden-brand i.ti { color: #fbbf24; }
|
||||||
|
html[data-theme-pair="sunset"] .nav-category { color: rgba(220, 190, 150, 0.6); }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"] ::selection { background: #fde68a; color: #78350f; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="light"] ::-moz-selection { background: #fde68a; color: #78350f; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] ::selection { background: #92400e; color: #fffbeb; }
|
||||||
|
html[data-theme-pair="sunset"][data-bs-theme="dark"] ::-moz-selection { background: #92400e; color: #fffbeb; }
|
||||||
|
|
||||||
|
/* ── Rose Theme ── */
|
||||||
|
html[data-theme-pair="rose"] {
|
||||||
|
--kw-primary: #db2777; --kw-primary-hover: #be185d; --kw-primary-active: #9d174d;
|
||||||
|
--tblr-primary: #db2777; --tblr-primary-rgb: 219,39,119;
|
||||||
|
}
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] {
|
||||||
|
--kw-primary: #f472b6; --kw-primary-hover: #ec4899; --kw-primary-active: #db2777;
|
||||||
|
--tblr-primary: #f472b6; --tblr-primary-rgb: 244,114,182;
|
||||||
|
--tblr-bg-surface: #22101a; --tblr-bg-surface-secondary: #281420;
|
||||||
|
--tblr-bg-surface-tertiary: #1e0c16; --tblr-bg-surface-dark: #1a0a14;
|
||||||
|
--tblr-bg-forms: #1e0c16; --tblr-body-bg: #1a0a14; --tblr-body-bg-rgb: 26,10,20;
|
||||||
|
--tblr-border-color: #3a1a2c; --tblr-border-color-translucent: rgba(244, 114, 182, 0.10);
|
||||||
|
}
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"],
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"] body { background-color: #fdf2f8 !important; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"],
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] body { background-color: #1a0a14 !important; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #831843 !important; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #12060e !important; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"] .page-wrapper { background: #fdf2f8; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] .page-wrapper,
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] .page-body { background: #1a0a14; }
|
||||||
|
html[data-theme-pair="rose"] .keywarden-header-brand .keywarden-brand i.ti { color: #f472b6; }
|
||||||
|
html[data-theme-pair="rose"] .nav-category { color: rgba(220, 160, 190, 0.6); }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"] ::selection { background: #fbcfe8; color: #831843; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="light"] ::-moz-selection { background: #fbcfe8; color: #831843; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] ::selection { background: #9f1239; color: #fdf2f8; }
|
||||||
|
html[data-theme-pair="rose"][data-bs-theme="dark"] ::-moz-selection { background: #9f1239; color: #fdf2f8; }
|
||||||
|
|
||||||
|
/* ── Nord Theme ── */
|
||||||
|
html[data-theme-pair="nord"] {
|
||||||
|
--kw-primary: #5e81ac; --kw-primary-hover: #4c6e96; --kw-primary-active: #3b5b80;
|
||||||
|
--tblr-primary: #5e81ac; --tblr-primary-rgb: 94,129,172;
|
||||||
|
}
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] {
|
||||||
|
--kw-primary: #88c0d0; --kw-primary-hover: #6eb0c2; --kw-primary-active: #5e9fb4;
|
||||||
|
--tblr-primary: #88c0d0; --tblr-primary-rgb: 136,192,208;
|
||||||
|
--tblr-bg-surface: #242830; --tblr-bg-surface-secondary: #2a2e36;
|
||||||
|
--tblr-bg-surface-tertiary: #21252c; --tblr-bg-surface-dark: #1e2128;
|
||||||
|
--tblr-bg-forms: #21252c; --tblr-body-bg: #1e2128; --tblr-body-bg-rgb: 30,33,40;
|
||||||
|
--tblr-border-color: #353a44; --tblr-border-color-translucent: rgba(136, 192, 208, 0.10);
|
||||||
|
}
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"],
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"] body { background-color: #eceff4 !important; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"],
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] body { background-color: #1e2128 !important; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"] header.navbar.keywarden-top-header { background: #2e3440 !important; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] .navbar-vertical,
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] header.navbar.keywarden-top-header { background: #14171c !important; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"] .page-wrapper { background: #eceff4; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] .page-wrapper,
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] .page-body { background: #1e2128; }
|
||||||
|
html[data-theme-pair="nord"] .keywarden-header-brand .keywarden-brand i.ti { color: #88c0d0; }
|
||||||
|
html[data-theme-pair="nord"] .nav-category { color: rgba(160, 180, 200, 0.6); }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"] ::selection { background: #d8dee9; color: #2e3440; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="light"] ::-moz-selection { background: #d8dee9; color: #2e3440; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] ::selection { background: #434c5e; color: #eceff4; }
|
||||||
|
html[data-theme-pair="nord"][data-bs-theme="dark"] ::-moz-selection { background: #434c5e; color: #eceff4; }
|
||||||
</style>
|
</style>
|
||||||
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
|
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
|
||||||
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
<link rel="stylesheet" href="/static/css/tabler.min.css">
|
||||||
@@ -344,6 +582,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
<div class="flex-grow-1"></div>
|
<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 -->
|
<!-- Repository Link -->
|
||||||
<div class="nav-item d-none d-md-flex me-2">
|
<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">
|
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
|
||||||
@@ -558,14 +805,36 @@
|
|||||||
<script src="/static/js/tabler.min.js"></script>
|
<script src="/static/js/tabler.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// --- Theme Toggle ---
|
// --- Theme Toggle ---
|
||||||
function getResolvedTheme() {
|
function parseTheme(theme) {
|
||||||
var stored = document.documentElement.getAttribute('data-bs-theme');
|
if (!theme || theme === 'auto' || theme === 'light' || theme === 'dark') {
|
||||||
return stored || 'light';
|
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) {
|
function resolveMode(mode) {
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
if (mode !== 'light' && mode !== 'dark') {
|
||||||
document.documentElement.style.colorScheme = theme;
|
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||||
updateThemeIcon(theme);
|
}
|
||||||
|
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) {
|
function updateThemeIcon(theme) {
|
||||||
var icon = document.getElementById('theme-icon');
|
var icon = document.getElementById('theme-icon');
|
||||||
@@ -573,15 +842,18 @@
|
|||||||
icon.className = theme === 'dark' ? 'ti ti-moon' : 'ti ti-sun';
|
icon.className = theme === 'dark' ? 'ti ti-moon' : 'ti ti-sun';
|
||||||
}
|
}
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
var current = getResolvedTheme();
|
var raw = window.__kwThemeRaw || 'auto';
|
||||||
var next = current === 'dark' ? 'light' : 'dark';
|
var parts = parseTheme(raw);
|
||||||
applyTheme(next);
|
var currentMode = getResolvedTheme();
|
||||||
// Persist choice via API (fire-and-forget)
|
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] || '';
|
var csrf = (document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/) || [])[1] || '';
|
||||||
fetch('/settings/theme', {
|
fetch('/settings/theme', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
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
|
// Set initial icon on page load
|
||||||
|
|||||||
@@ -11,10 +11,32 @@
|
|||||||
<div class="row align-items-end">
|
<div class="row align-items-end">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Theme</label>
|
<label class="form-label">Theme</label>
|
||||||
<select name="theme" class="form-select" style="width: 250px;">
|
<select name="theme" class="form-select" style="width: 280px;">
|
||||||
<option value="auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto")}}selected{{end}}>Automatic (System)</option>
|
<optgroup label="🌊 Ocean (Standard)">
|
||||||
<option value="light" {{if and .User (eq .User.Theme "light")}}selected{{end}}>Light</option>
|
<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="dark" {{if and .User (eq .User.Theme "dark")}}selected{{end}}>Dark</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
|
|||||||
@@ -9,6 +9,17 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{with .SystemInfo}}
|
{{with .SystemInfo}}
|
||||||
<div class="datagrid">
|
<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-item">
|
||||||
<div class="datagrid-title">Runtime Environment</div>
|
<div class="datagrid-title">Runtime Environment</div>
|
||||||
<div class="datagrid-content">
|
<div class="datagrid-content">
|
||||||
|
|||||||
@@ -78,11 +78,13 @@
|
|||||||
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||||
<i class="ti ti-edit"></i>
|
<i class="ti ti-edit"></i>
|
||||||
</a>
|
</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?')">
|
<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">
|
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
||||||
<i class="ti ti-trash"></i>
|
<i class="ti ti-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -38,6 +38,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label required">Role</label>
|
<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">
|
<select name="role" class="form-select">
|
||||||
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
|
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
|
||||||
{{with $.User}}
|
{{with $.User}}
|
||||||
@@ -47,6 +54,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">MFA Status</label>
|
<label class="form-label">MFA Status</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user