Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s

This commit is contained in:
2026-04-05 16:56:16 +02:00
parent 23ff731579
commit fd13e67aef
89 changed files with 18786 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# ============================================================
# Keywarden Environment Configuration
# ============================================================
# Copy this file to .env and adjust the values.
# cp .env.example .env
#
# The .env file is loaded automatically by Docker Compose
# and is excluded from version control via .gitignore.
# ============================================================
# --- Application ---
KEYWARDEN_PORT=8080
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@keywarden.local
KEYWARDEN_SESSION_KEY=change-me-to-a-random-string
KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
# --- Logging ---
# Log level: ERROR, WARN, INFO (default), DEBUG, TRACE
KEYWARDEN_LOG_LEVEL=INFO
# --- Paths (optional, Docker defaults are usually fine) ---
KEYWARDEN_DB_PATH=./data/keywarden.db
KEYWARDEN_DATA_DIR=./data
KEYWARDEN_KEYS_DIR=./data/keys
KEYWARDEN_MASTER_DIR=./data/master
# --- Security / Hardening (optional) ---
# Public URL used for email links and cookie config.
KEYWARDEN_BASE_URL=https://keywarden.example.com
# Comma-separated CIDRs of trusted reverse proxies.
KEYWARDEN_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
# Set Secure flag on cookies (auto-derived from BASE_URL if empty).
KEYWARDEN_SECURE_COOKIES=true
# Max login POST attempts per IP per minute (0 = disabled).
KEYWARDEN_RATE_LIMIT_LOGIN=10
# Max request body size in bytes (0 = no limit, default 10 MB).
KEYWARDEN_MAX_REQUEST_SIZE=10485760
# --- SMTP / Email (optional) ---
# Leave KEYWARDEN_SMTP_HOST empty or remove it to disable email.
KEYWARDEN_SMTP_HOST=
KEYWARDEN_SMTP_PORT=587
KEYWARDEN_SMTP_USER=
KEYWARDEN_SMTP_PASSWORD=
KEYWARDEN_SMTP_FROM=keywarden@example.com
KEYWARDEN_SMTP_TLS=true
+39
View File
@@ -0,0 +1,39 @@
# Keywarden CI - Pull Request Tests
# Runs on every PR to master: vet, build, test (with CGO for SQLite)
name: PR Tests
on:
pull_request:
branches: [master]
jobs:
test:
name: Lint, Build & Test
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install build dependencies
run: apk add --no-cache gcc musl-dev sqlite-dev git
- name: Checkout code
uses: actions/checkout@v4
- name: Go module cache
uses: actions/cache@v4
with:
path: /go/pkg/mod
key: go-mod-${{ hashFiles('go.sum') }}
- name: Download dependencies
run: go mod download
- name: Go vet
run: go vet ./...
- name: Build
run: CGO_ENABLED=1 go build -o /dev/null ./cmd/keywarden/
- name: Run tests
run: CGO_ENABLED=1 go test -tags integration ./internal/... -v -count=1 -timeout 120s
+55
View File
@@ -0,0 +1,55 @@
# Keywarden CI - Release Docker Build
# Triggers when a release is published (tag format: v0.1.0)
# Builds and pushes Docker image to Gitea Container Registry with :latest and :vX.Y.Z tags
name: Release Docker Image
on:
release:
types: [published]
env:
IMAGE_NAME: keywarden
jobs:
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: |
# Release tag is e.g. v0.1.0
TAG="${{ github.event.release.tag_name }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Strip 'v' prefix for docker tag if needed
VERSION="${TAG#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ vars.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:latest
${{ vars.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
labels: |
org.opencontainers.image.title=Keywarden
org.opencontainers.image.description=Centralized SSH Key Management and Deployment
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.licenses=AGPL-3.0-or-later
+27
View File
@@ -0,0 +1,27 @@
# Keywarden CI - Security Scan
# Checks for known vulnerabilities in Go dependencies on PRs
name: Security Scan
on:
pull_request:
branches: [master]
jobs:
govulncheck:
name: Go Vulnerability Check
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install dependencies
run: apk add --no-cache git gcc musl-dev sqlite-dev
- name: Checkout code
uses: actions/checkout@v4
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
+43
View File
@@ -0,0 +1,43 @@
# Binaries
/keywarden
/keywarden.exe
*.exe
*.exe~
*.dll
*.so
*.dylib
# Build output
/build/
/dist/
# Go
vendor/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# App data (runtime)
/data/
*.db
*.db-journal
*.db-wal
*.db-shm
# Environment
.env
.env.local
# Keys (never commit private keys!)
*.pem
*.key
id_rsa
id_ed25519
+45
View File
@@ -0,0 +1,45 @@
# Keywarden - Centralized SSH Key Management and Deployment
# Multi-stage build for minimal image size
# Stage 1: Build
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache gcc musl-dev sqlite-dev
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl
RUN addgroup -S keywarden && adduser -S keywarden -G keywarden
WORKDIR /app
COPY --from=builder /build/keywarden .
RUN mkdir -p /data/keys /data/master /data/avatars && \
chown -R keywarden:keywarden /data /app
USER keywarden
ENV KEYWARDEN_PORT=8080
ENV KEYWARDEN_DB_PATH=/data/keywarden.db
ENV KEYWARDEN_DATA_DIR=/data
ENV KEYWARDEN_KEYS_DIR=/data/keys
ENV KEYWARDEN_MASTER_DIR=/data/master
ENV KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${KEYWARDEN_PORT:-8080}/api/health || exit 1
ENTRYPOINT ["./keywarden"]
+116
View File
@@ -0,0 +1,116 @@
# Keywarden
**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
> **Keywarden is currently in alpha status.**
>
> - **Do NOT expose this application directly to the public internet.** Use it only in trusted, private networks.
> - The software may contain bugs, incomplete features, or security issues.
> - **Your feedback is valuable!** If you discover bugs or have suggestions, please report them at [git.techniverse.net/scriptos/keywarden](https://git.techniverse.net/scriptos/keywarden). Every report helps improve the project.
---
## Features
- **SSH Key Management** — Generate (RSA 2048/4096, Ed25519, Ed448) or import existing keys
- **Encrypted Storage** — Private keys encrypted at rest with AES-256-GCM
- **Server & Group Management** — Register servers, organize into groups
- **Access Assignments** — Declarative access model: assign users + keys to servers with system user, sudo, and user creation
- **Temporary Access** — Schedule time-limited access with automatic expiry (key removal, user disable, or user deletion)
- **Three-Tier Roles** — Owner, Admin, and User with distinct permissions
- **User Invitations** — Invite users via secure email links
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
- **Audit Log** — Every action tracked with user, IP, timestamp, and details
- **Encrypted Backup/Restore** — Full database export with password-based encryption
- **Docker-Native** — Single container with embedded SQLite, no external database required
---
## Quick Start
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
### 1. Clone and configure
```bash
git clone https://git.techniverse.net/scriptos/keywarden.git
cd keywarden
```
Create a `.env` file:
```env
KEYWARDEN_SESSION_KEY=your-random-session-key-at-least-32-characters
KEYWARDEN_ENCRYPTION_KEY=your-random-encryption-key-at-least-32-chars
```
> **Important:** Change both keys to unique random strings. The encryption key protects all stored SSH private keys — if lost, they cannot be recovered.
### 2. Start
```bash
docker compose up -d
```
### 3. Get the initial password
```bash
docker compose logs keywarden
```
Look for the auto-generated admin password in the output:
```
════════════════════════════════════════════════════════════
Initial owner account created
Username: admin
Password: <auto-generated>
Please change this password after first login!
════════════════════════════════════════════════════════════
```
### 4. Open
Navigate to `http://your-host:8080` and log in. You will be prompted to change the password.
### 5. Deploy the master key
After login, copy the **system master key** (shown in Admin Settings and in the startup logs) and add it to the `authorized_keys` of the root user on every server you want to manage:
```bash
echo "ssh-ed25519 AAAA... keywarden-system-master" >> /root/.ssh/authorized_keys
```
---
## Documentation
For detailed documentation, see the [docs/](docs/README.md) folder:
- [Quick Start Guide](docs/quickstart.md)
- [Installation & Deployment](docs/deployment.md) — Docker, reverse proxy, HTTPS
- [Architecture](docs/architecture.md) — System design and components
- [User Guide](docs/user-guide.md) — SSH keys, settings, MFA
- [Admin Guide](docs/admin-guide.md) — Servers, deployments, access assignments, cron jobs
- [Roles & Permissions](docs/roles.md) — Owner, Admin, User role details
- [Security](docs/security.md) — Encryption, authentication, hardening
- [Environment Variables](docs/environment-variables.md) — Full configuration reference
- [Email Configuration](docs/email.md) — SMTP, notifications, invitations
- [Backup & Restore](docs/backup-restore.md) — Encrypted database backup
- [Troubleshooting](docs/troubleshooting.md) — Common issues and solutions
- [Contributing](docs/contributing.md) — Development setup and guidelines
---
## License
Keywarden is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0-or-later)](LICENSE).
© 2026 Patrick Asmus ([scriptos](https://git.techniverse.net/scriptos))
+139
View File
@@ -0,0 +1,139 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"net/http"
"os"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
"git.techniverse.net/scriptos/keywarden/internal/config"
"git.techniverse.net/scriptos/keywarden/internal/cron"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/deploy"
"git.techniverse.net/scriptos/keywarden/internal/encryption"
"git.techniverse.net/scriptos/keywarden/internal/handlers"
"git.techniverse.net/scriptos/keywarden/internal/keys"
"git.techniverse.net/scriptos/keywarden/internal/logging"
"git.techniverse.net/scriptos/keywarden/internal/mail"
"git.techniverse.net/scriptos/keywarden/internal/security"
"git.techniverse.net/scriptos/keywarden/internal/servers"
"git.techniverse.net/scriptos/keywarden/web"
)
func main() {
// Load config first (needed for log level)
cfg := config.Load()
// Initialize structured logging
logging.Init(cfg.LogLevel)
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
// Ensure data directories exist
for _, dir := range []string{cfg.DataDir, cfg.KeysDir, cfg.MasterDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
logging.Fatal("Failed to create directory %s: %v", dir, err)
}
}
// Initialize database
db, err := database.New(cfg.DBPath)
if err != nil {
logging.Fatal("Failed to initialize database: %v", err)
}
defer db.Close()
logging.Info("Database initialized")
// Initialize services
encSvc := encryption.NewService(cfg.EncryptionKey)
authSvc := auth.NewService(db)
keysSvc := keys.NewService(db, encSvc)
serversSvc := servers.NewService(db)
deploySvc := deploy.NewService(db)
auditSvc := audit.NewService(db)
cronSvc := cron.NewService(db, deploySvc, keysSvc, serversSvc, auditSvc)
mailSvc := mail.NewService(cfg)
// Create default owner if no users exist (password is auto-generated)
adminUser := getEnv("KEYWARDEN_ADMIN_USER", "admin")
adminEmail := getEnv("KEYWARDEN_ADMIN_EMAIL", "admin@keywarden.local")
created, generatedPass, err := authSvc.EnsureAdmin(adminUser, adminEmail)
if err != nil {
logging.Fatal("Failed to create admin user: %v", err)
}
if created {
logging.Info("════════════════════════════════════════════════════════════")
logging.Info(" Initial owner account created")
logging.Info(" Username: %s", adminUser)
logging.Info(" Password: %s", generatedPass)
logging.Info(" Please change this password after first login!")
logging.Info("════════════════════════════════════════════════════════════")
}
// Ensure system master key exists (generated on first startup)
masterPub, err := keysSvc.EnsureSystemMasterKey()
if err != nil {
logging.Fatal("Failed to ensure system master key: %v", err)
}
logging.Info("System master key ready (deploy this public key to your servers)")
logging.Info("Master key: %s", masterPub)
// Initialize security subsystem (trusted proxy IP extraction)
security.Init(cfg.TrustedProxies)
if cfg.TrustedProxies != "" {
logging.Info("Trusted proxies: %s", cfg.TrustedProxies)
} else {
logging.Warn("KEYWARDEN_TRUSTED_PROXIES not set proxy headers (X-Forwarded-For) are trusted unconditionally. Configure trusted proxies for production use.")
}
if cfg.BaseURL != "" {
logging.Info("Base URL: %s", cfg.BaseURL)
}
// Setup HTTP handlers
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Start session cleanup (removes expired sessions periodically)
handler.StartSessionCleanup()
// Build middleware chain (innermost → outermost)
var h http.Handler = mux
h = security.CSRFMiddleware(cfg.SecureCookies)(h)
h = security.SizeLimitMiddleware(cfg.MaxRequestSize)(h)
h = security.RateLimitMiddleware(cfg.RateLimitLogin)(h)
h = security.HeadersMiddleware()(h)
h = logging.RequestLogger(handler.GetUserName, security.ClientIP)(h)
logging.Info("Security hardening active: CSRF protection, security headers, rate limiting (%d/min login), request size limit (%d bytes)",
cfg.RateLimitLogin, cfg.MaxRequestSize)
if cfg.SecureCookies {
logging.Info("Secure cookies enabled (HTTPS mode)")
}
// Start cron scheduler
cronSvc.Start()
defer cronSvc.Stop()
// Start server
addr := ":" + cfg.Port
logging.Info("Server starting on http://0.0.0.0%s", addr)
if err := http.ListenAndServe(addr, h); err != nil {
logging.Fatal("Server failed: %v", err)
}
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}
+21
View File
@@ -0,0 +1,21 @@
services:
keywarden:
build: .
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${KEYWARDEN_PORT:-8080}/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes:
keywarden_data:
driver: local
+51
View File
@@ -0,0 +1,51 @@
# Keywarden Documentation
Welcome to the official documentation for **Keywarden** — a self-hosted, centralized SSH key management and deployment platform.
## Table of Contents
1. [Quick Start Guide](quickstart.md) — Get Keywarden running in minutes
2. [Installation & Deployment](deployment.md) — Docker setup, reverse proxy, production deployment
3. [Architecture Overview](architecture.md) — System design, components, technology stack
4. [User Guide](user-guide.md) — Day-to-day usage for all users
5. [Administration Guide](admin-guide.md) — Server management, deployments, access assignments, cron jobs
6. [Roles & Permissions](roles.md) — Owner, Admin, and User role details
7. [Security](security.md) — Authentication, MFA, encryption, CSRF, hardening
8. [Environment Variables](environment-variables.md) — Complete configuration reference
9. [Email Configuration](email.md) — SMTP setup, login notifications, invitations
10. [API Reference](api-reference.md) — Health check endpoint and internal APIs
11. [Backup & Restore](backup-restore.md) — Encrypted database backup and restore
12. [Troubleshooting](troubleshooting.md) — Common issues and solutions
13. [Contributing](contributing.md) — Development setup, project structure, guidelines
---
## What is Keywarden?
Keywarden provides a clean web UI to generate, import, and securely store SSH keys (RSA, Ed25519, and Ed448) in one central place. Managed keys can then be deployed to registered Linux servers — individually or via server groups — with a single click. A complete audit log tracks every action in the system.
### Key Features
- **SSH Key Management** — Generate (RSA 2048/4096, Ed25519, Ed448) or import existing key pairs
- **Encrypted Storage** — All private keys stored with AES-256-GCM encryption at rest
- **System Master Key** — Auto-generated Ed25519 key used for all server authentication
- **Host & Group Management** — Register servers, organize them into groups, deploy keys to one or many
- **Access Assignments** — Map users + keys to target hosts/groups with specific system users, sudo rights, and user creation
- **Temporary Access (Cron Jobs)** — Schedule time-limited access with automatic key removal, user disabling, or user deletion on expiry
- **Three-Tier Role System** — Owner, Admin, and User roles with clear permission boundaries
- **User Invitations** — Invite new users via secure email links with self-service password setup
- **TOTP Two-Factor Authentication** — Optional or enforced MFA for all users
- **Password Policies** — Configurable complexity requirements with account lockout
- **Email Notifications** — Login alerts and invitation emails via SMTP
- **Comprehensive Audit Log** — Every action logged with user, timestamp, IP address, and details
- **Encrypted Backup/Restore** — Full database export with AES-256 password encryption
- **Docker-Native** — Single-container deployment with SQLite, no external database needed
- **Security Hardened** — CSRF protection, CSP headers, rate limiting, request size limits
---
## License
Keywarden is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0-or-later)](../LICENSE).
© 2026 Patrick Asmus ([scriptos](https://git.techniverse.net/scriptos))
+248
View File
@@ -0,0 +1,248 @@
# Administration Guide
This guide covers administrative features available to users with the **Admin** or **Owner** role. For basic user operations, see the [User Guide](user-guide.md). For role details, see [Roles & Permissions](roles.md).
## Server Management
Admins manage the inventory of remote SSH servers that Keywarden can deploy keys to.
### Adding a Server
1. Navigate to **Servers****Add Server**
2. Fill in:
- **Name** — Descriptive name (e.g., "Web Server 1")
- **Hostname** — IP address or DNS name
- **Port** — SSH port (default: 22)
- **Username** — SSH admin user for connections (typically `root`)
- **Description** — Optional description
- **Server Groups** — Optionally assign the server to one or more groups
3. Click **Save**
### Testing Server Connectivity
From the server list, you can run two types of tests:
- **Connection Test** — TCP connectivity check (is the port reachable?)
- **Auth Test** — Full SSH authentication test using the system master key
Both tests help verify that Keywarden can reach and authenticate to the server.
### Editing / Deleting Servers
Use the edit and delete buttons on the server list. Deleting a server removes it from all groups and cancels related assignments.
## Server Groups
Server groups allow you to organize servers and deploy keys to multiple servers at once.
### Creating a Group
1. Navigate to **Groups****Add Group**
2. Enter a **name** and optional **description**
3. Click **Create**
### Managing Group Members
From the group edit page:
- **Add servers** to the group by selecting them from the list of all servers
- **Remove servers** from the group
Server groups are used as targets for:
- Group deployments
- Access assignments
- Cron jobs (temporary access)
## Key Deployment
### Manual Deployment
1. Navigate to **Deploy**
2. Select an **SSH key** from the dropdown (shows all keys from all users)
3. Select a **target server**
4. Click **Deploy**
Keywarden connects to the target server using the system master key and appends the selected public key to the server user's `~/.ssh/authorized_keys`.
### Group Deployment
1. Navigate to **Deploy**
2. Select an **SSH key**
3. Select a **server group**
4. Click **Deploy to Group**
The key is deployed to all servers in the group sequentially.
### Deployment History
The deploy page shows the last 50 deployment results with status (success/failed) and error messages.
## Access Assignments
Access assignments are the core feature for managing who has access to which servers. They provide a declarative model: define the desired state, and Keywarden syncs it to the servers.
### Creating an Assignment
1. Navigate to **Assignments****Add Assignment**
2. Fill in:
- **User** — The Keywarden user to grant access to
- **SSH Key** — Which key to deploy (from that user's keys)
- **Target Type** — Single server or server group
- **Target** — Select the server or group
- **System User** — The Linux username on the target server
- **Desired State** — `present` (deploy key) or `absent` (remove key)
- **Sudo** — Grant NOPASSWD sudo privileges to the system user
- **Create User** — Create the Linux user if it doesn't exist
3. Click **Save**
After creation, the assignment is **automatically synced** — Keywarden immediately connects to the target server(s) and applies the configuration.
### What Sync Does
When an assignment is synced with `desired_state = "present"`:
1. **Creates the system user** (if `create_user` is enabled and user doesn't exist)
- Uses `useradd -m -s /bin/bash`
- Sets an initial password if one is configured (auto-generated if empty)
2. **Adds sudo privileges** (if `sudo` is enabled)
- Creates `/etc/sudoers.d/<username>` with `NOPASSWD:ALL`
3. **Deploys the SSH public key** to the system user's `authorized_keys`
When an assignment is synced with `desired_state = "absent"`:
- Removes the SSH key from the system user's `authorized_keys`
### Manual Re-Sync
Click the **Sync** button on any assignment to re-apply it. This is useful if the server was reinstalled or if a previous sync failed.
### Deleting an Assignment
When deleting an assignment, you have two options:
- **Remove key only** — Only removes the SSH key from the system user's `authorized_keys`
- **Delete system user** — Completely removes the system user account, their home directory, and sudo privileges from the target server(s)
The cleanup operation runs on all target servers (including all servers in a group).
### Assignment Status
| Status | Meaning |
|---|---|
| `pending` | Not yet synced |
| `synced` | Successfully applied to all targets |
| `failed` | Sync failed (see error message) |
## Cron Jobs (Temporary Access)
Cron jobs provide time-limited access to servers. They are essentially scheduled access assignments with an expiry.
### Creating a Cron Job
1. Navigate to **Temporary Access****Add Job**
2. Configure:
- **Name** — Descriptive job name
- **Target User** — Keywarden user to grant access to
- **SSH Key** — Which key to deploy
- **Target** — Single server or server group
- **System User** — Linux username on the target
- **Create User** — Create the system user if needed
- **Sudo** — Grant sudo privileges
- **Schedule** — `once`, `hourly`, `daily`, `weekly`, or `monthly`
- **Scheduled Time** — When the job should run (timezone-aware)
- **Remove After** — Minutes after deployment to remove access (0 = permanent)
- **Expiry Action** — What to do when access expires:
- `remove_key` — Only remove the SSH key
- `disable_user` — Lock the account and set shell to nologin
- `delete_user` — Completely delete the system user
3. Click **Save**
### Schedule Types
| Schedule | Parameters | Behavior |
|---|---|---|
| `once` | Date + Time | Runs exactly once at the specified time |
| `hourly` | Minute of hour | Runs every hour at the specified minute |
| `daily` | Time of day | Runs every day at the specified time |
| `weekly` | Day of week + Time | Runs every week on the specified day |
| `monthly` | Day of month + Time | Runs monthly (clamped to last day if needed) |
### Cron Job Lifecycle
1. **Active** — Waiting for next run
2. **Running** — Currently executing
3. **Done** — One-time job completed
4. **Paused** — Manually paused by admin
5. **Failed** — Execution failed (recurring jobs stay active, one-time jobs remain failed)
### Expiry Timer
When `remove_after_min > 0`, a background timer starts after successful deployment. When it fires, the configured expiry action is executed on all target servers.
## User Management
### Creating Users
1. Navigate to **Users****Add User**
2. Fill in:
- **Username** — Must be unique
- **Email** — Must be unique
- **Role** — `user`, `admin`, or `owner` (see [Roles & Permissions](roles.md))
- **Password** — Set a password, or...
- **Send Invitation** — If email is configured, send an invitation email instead of setting a password
3. Click **Create**
When using invitations, the user receives an email with a secure link to set their own password.
### Editing Users
Admins can change a user's username, email, and role. Additional actions:
- **Reset password** — Set a new password (the user will be prompted to change it)
- **Force password change** — Flag the user to change password on next login
- **Unlock account** — Clear a lockout from failed login attempts
### Deleting Users
Deleting a user removes their SSH keys, server records, and all related data (CASCADE delete).
> **Protection:** You cannot delete the last owner account.
## System Information
Navigate to **System** to view runtime information:
- Go version, OS, architecture
- CPU count, goroutine count
- Memory allocation
- Runtime environment (Docker or native)
- Hostname and uptime
## Admin Settings (Owner Only)
See [Roles & Permissions](roles.md) for details on which settings are owner-only.
Navigate to **Admin Settings** (owner only) to configure:
### Application Settings
- **App Name** — Custom application name displayed in the UI
- **Default Key Type** — Default key type for generation (ed25519, rsa)
- **Default Key Bits** — Default key size
- **Session Timeout** — Inactivity timeout in minutes (default: 60)
### Security Settings
- **Password Policy** — Minimum length, uppercase, lowercase, digit, special character requirements
- **Account Lockout** — Number of failed attempts before lockout and lockout duration
- **MFA Enforcement** — Require all users to enable TOTP MFA
### Master Key
- View the system master key's public key and fingerprint
- **Regenerate** the master key (requires password confirmation)
### Email Test
Send a test email to verify SMTP configuration.
### Backup & Restore
See [Backup & Restore](backup-restore.md) for details.
+154
View File
@@ -0,0 +1,154 @@
# API Reference
Keywarden is primarily a web application with server-rendered HTML pages. It provides a limited JSON API for health monitoring and internal use.
## Public Endpoints
### Health Check
```
GET /api/health
```
Returns the application health status. No authentication required. Used by Docker HEALTHCHECK and external monitoring tools.
**Response (healthy):**
```json
{
"status": "healthy",
"uptime": "2d 5h 30m",
"uptime_seconds": 194400,
"checks": {
"database": {
"status": "ok"
}
}
}
```
**Response (unhealthy):**
```json
{
"status": "unhealthy",
"uptime": "0m",
"uptime_seconds": 30,
"checks": {
"database": {
"status": "fail"
}
}
}
```
| Status Code | Meaning |
|---|---|
| `200 OK` | Application is healthy |
| `503 Service Unavailable` | Database unreachable or other critical failure |
## Internal API Endpoints
These endpoints require authentication and are used by the web UI.
### Cron Keys API
```
GET /api/cron/keys?user_id={id}
```
Returns SSH keys for a specific user as JSON. Used by the cron job creation form to dynamically load keys when the target user is selected.
**Access**: Admin and Owner only.
**Response:**
```json
[
{
"id": 1,
"name": "My Ed25519 Key",
"key_type": "ed25519",
"fingerprint": "SHA256:abc..."
}
]
```
## HTTP Routes Overview
### Public Routes (No Authentication)
| Method | Path | Description |
|---|---|---|
| GET/POST | `/login` | Login page and authentication |
| POST | `/login/mfa` | MFA code verification |
| GET | `/logout` | Session termination |
| GET/POST | `/invite/{token}` | Invitation acceptance |
### Authenticated Routes (All Users)
| Method | Path | Description |
|---|---|---|
| GET | `/` | Redirect to dashboard |
| GET | `/dashboard` | Main dashboard |
| GET/POST | `/password/change` | Forced password change |
| GET | `/keys` | SSH key list |
| GET/POST | `/keys/generate` | Generate new SSH key |
| GET/POST | `/keys/import` | Import existing SSH key |
| GET/POST | `/keys/{id}/{action}` | Key actions (download, delete) |
| GET/POST | `/settings` | User account settings |
| POST | `/settings/theme` | Change theme preference |
| GET/POST | `/settings/mfa/setup` | Enable MFA |
| POST | `/settings/mfa/disable` | Disable MFA |
| POST | `/settings/email/notify` | Toggle email notifications |
| POST | `/settings/avatar` | Upload profile picture |
| GET | `/avatar/{id}` | Serve user avatar |
| GET | `/audit` | Audit log viewer |
| GET | `/my/access` | View own access assignments |
| GET/POST | `/mfa/setup` | MFA enforcement setup page |
### Admin Routes (Admin + Owner)
| Method | Path | Description |
|---|---|---|
| GET | `/servers` | Server list |
| GET/POST | `/servers/add` | Add server |
| POST | `/servers/test` | Test server connection |
| POST | `/servers/test-auth` | Test SSH authentication |
| GET/POST | `/servers/{id}/{action}` | Edit/delete server |
| GET | `/groups` | Server group list |
| GET/POST | `/groups/add` | Add server group |
| GET/POST | `/groups/{id}/{action}` | Edit/delete group, manage members |
| GET/POST | `/deploy` | Manual key deployment |
| POST | `/deploy/group` | Group deployment |
| GET | `/cron` | Cron job list |
| GET/POST | `/cron/add` | Add cron job |
| GET/POST | `/cron/{id}/{action}` | Edit/delete/pause cron job |
| GET | `/users` | User list |
| GET/POST | `/users/add` | Add user |
| GET/POST | `/users/{id}/{action}` | Edit/delete/unlock user |
| GET | `/assignments` | Access assignment list |
| GET/POST | `/assignments/add` | Add assignment |
| GET/POST | `/assignments/{id}/{action}` | Edit/delete/sync assignment |
| GET | `/system` | System information |
| GET | `/api/cron/keys` | Get keys by user (JSON) |
### Owner Routes
| Method | Path | Description |
|---|---|---|
| GET/POST | `/admin/settings` | Application and security settings |
| POST | `/admin/settings/email/test` | Send test email |
| POST | `/admin/masterkey/regenerate` | Regenerate system master key |
| POST | `/admin/backup/export` | Export encrypted database backup |
| POST | `/admin/backup/import` | Import encrypted database backup |
## Static Assets
| Path | Description |
|---|---|
| `/static/css/` | Tabler CSS framework |
| `/static/js/` | Tabler JavaScript |
| `/static/css/fonts/` | Tabler icons font |
Static assets are embedded in the binary and served with long cache headers.
+152
View File
@@ -0,0 +1,152 @@
# Architecture Overview
This document describes the system architecture and design of Keywarden.
## Technology Stack
| Component | Technology |
|---|---|
| Language | Go 1.26 |
| Database | SQLite 3 (WAL mode, embedded) |
| Web Framework | Go standard library (`net/http`) |
| Template Engine | Go `html/template` |
| UI Framework | [Tabler](https://tabler.io) (Bootstrap-based) |
| SSH Library | `golang.org/x/crypto/ssh` |
| Encryption | AES-256-GCM (Go `crypto/aes`, `crypto/cipher`) |
| Password Hashing | bcrypt (`golang.org/x/crypto/bcrypt`) |
| Ed448 Support | `github.com/cloudflare/circl` |
| Containerization | Docker (Alpine Linux) |
## Application Structure
Keywarden is a single Go binary with embedded static assets and templates. It serves a web UI and handles all SSH operations internally.
```
cmd/keywarden/main.go ← Application entry point
internal/
audit/ ← Audit logging service
auth/ ← Authentication, users, MFA, password policy, invitations
config/ ← Environment-based configuration
cron/ ← Scheduled temporary access jobs
database/ ← SQLite connection, migrations, backup/restore
deploy/ ← SSH key deployment to remote servers
encryption/ ← AES-256-GCM encryption service
handlers/ ← HTTP handlers and routing (all UI logic)
keys/ ← SSH key management, system master key
logging/ ← Structured logging with levels
mail/ ← SMTP email service (notifications, invitations)
models/ ← Data models (User, SSHKey, Server, etc.)
security/ ← CSRF, security headers, rate limiting, proxy detection
servers/ ← Server and server group management, access assignments
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
web/
embed.go ← Go embed directives for templates and static files
static/ ← CSS, JS, fonts (Tabler UI framework)
templates/ ← HTML templates (Go template syntax)
```
## Startup Sequence
1. **Load configuration** from environment variables
2. **Initialize logging** with the configured log level
3. **Create data directories** (`/data`, `/data/keys`, `/data/master`)
4. **Initialize SQLite database** with WAL mode and run all migrations
5. **Initialize services**: encryption, auth, keys, servers, deploy, audit, cron, mail
6. **Create initial owner account** (if no users exist) with auto-generated password
7. **Ensure system master key** exists (generates on first run)
8. **Configure security** subsystem (trusted proxy parsing)
9. **Set up HTTP routes** and load templates
10. **Start session cleanup** goroutine (removes expired sessions every minute)
11. **Apply middleware chain**: request logger → security headers → rate limiting → size limiting → CSRF
12. **Start cron scheduler** (checks for pending jobs every 30 seconds)
13. **Start HTTP server**
## Database Design
Keywarden uses SQLite with the following tables:
| Table | Purpose |
|---|---|
| `users` | User accounts (username, email, password hash, role, MFA, themes) |
| `ssh_keys` | SSH key pairs (public key, encrypted private key, fingerprint) |
| `servers` | Managed remote servers (hostname, port, SSH username) |
| `server_groups` | Named groups of servers |
| `server_group_members` | Many-to-many relation: servers ↔ groups |
| `access_assignments` | Maps users + keys → hosts/groups with system user config |
| `cron_jobs` | Scheduled temporary access jobs |
| `key_deployments` | Deployment history log |
| `audit_log` | Full audit trail of all actions |
| `settings` | Key-value application settings |
| `invitation_tokens` | One-time invitation links for new users |
| `_migrations` | Tracks applied database migrations |
Database migrations are idempotent and run automatically on every startup. New columns are added via `ALTER TABLE` with migration tracking to prevent duplicate additions.
## Request Flow
```
Client → [Nginx/Caddy] → Keywarden HTTP Server
├── Request Logger Middleware
├── Security Headers Middleware
├── Rate Limit Middleware (login endpoints)
├── Size Limit Middleware
├── CSRF Middleware (double-submit cookie)
├── Public Routes (/login, /invite/*)
├── Auth Routes (requireAuth → all authenticated users)
├── Admin Routes (requireAdmin → admin + owner)
└── Owner Routes (requireOwner → owner only)
```
## Session Management
Sessions are stored in-memory (not in the database) as a map of session tokens to session data. Each session tracks:
- User ID
- Last activity timestamp (for sliding timeout)
- MFA setup requirement flag
Session tokens are cryptographically random 32-byte hex strings stored in an HTTP-only cookie (`keywarden_session`). Sessions expire after the configured timeout (default: 60 minutes of inactivity). A background goroutine cleans up expired sessions every minute.
## Encryption Architecture
### Private Key Storage
All SSH private keys are encrypted at rest using AES-256-GCM:
1. The `KEYWARDEN_ENCRYPTION_KEY` environment variable is hashed with SHA-256 to derive a 32-byte AES key
2. Each private key is encrypted with a random 12-byte nonce
3. The encrypted blob (nonce + ciphertext + GCM tag) is base64-encoded and stored in the database
### Backup Encryption
Database backups are encrypted with a user-provided password using the same AES-256-GCM scheme. The password is hashed with SHA-256 to derive the encryption key.
### Password Storage
User passwords are hashed with bcrypt at the default cost factor.
## System Master Key
The system master key is an Ed25519 SSH key pair generated on first startup. It is used for all SSH connections to managed servers. The private key is stored encrypted in the `settings` table.
The master key enables Keywarden to:
- Deploy SSH keys to server `authorized_keys`
- Create and manage system users on remote servers
- Add/remove sudo privileges
- Disable or delete system users during access expiry
## Cron Scheduler
The cron service runs a tick every 30 seconds, checking for jobs where `next_run <= now` and `status == 'active'`. For each due job, it:
1. Marks the job as `running`
2. Resolves the SSH key and target servers
3. Deploys the key to the specified system user
4. If `remove_after_min > 0`, starts a background timer that triggers the expiry action
5. Updates the job status and calculates the next run time
Supported schedules: `once`, `hourly`, `daily`, `weekly`, `monthly`.
Expiry actions: `remove_key` (default), `disable_user`, `delete_user`.
+93
View File
@@ -0,0 +1,93 @@
# Backup & Restore
Keywarden provides a built-in encrypted backup and restore feature for the entire database. This is an **owner-only** feature accessible from the Admin Settings page.
## Overview
- Backups include **all data**: users, SSH keys (encrypted), servers, groups, assignments, cron jobs, settings, audit log, and deployment history
- Backups are encrypted with a **user-provided password** using AES-256-GCM
- Backup files use the `.kwbak` extension
- Restoring a backup **completely replaces** the current database
## Exporting a Backup
1. Navigate to **Admin Settings** (owner only)
2. In the **Backup & Restore** section, enter a **backup password** and confirm it
3. The password must comply with the configured password policy
4. Click **Export Backup**
5. A file named `keywarden-backup-{timestamp}.kwbak` is downloaded
### What's Included
| Data | Included |
|---|---|
| Users (with password hashes, MFA secrets) | ✅ |
| SSH Keys (with encrypted private keys) | ✅ |
| Servers | ✅ |
| Server Groups + Members | ✅ |
| Access Assignments | ✅ |
| Cron Jobs | ✅ |
| Key Deployment History | ✅ |
| Audit Log | ✅ |
| Application Settings | ✅ |
> **Note:** SSH private keys are stored with double encryption in backups — first with the application's `KEYWARDEN_ENCRYPTION_KEY`, then with the backup password. Both keys are needed to access the private keys.
## Importing a Backup
1. Navigate to **Admin Settings** (owner only)
2. In the **Backup & Restore** section, select the `.kwbak` file
3. Enter the **backup password** that was used during export
4. Click **Import Backup**
### Important Warnings
- **Importing a backup completely replaces all data** in the current database
- All current users, keys, servers, and settings are deleted and replaced
- The current session remains valid (you stay logged in as the owner)
- After import, you may need to log in again with credentials from the backup
- The `KEYWARDEN_ENCRYPTION_KEY` must match the one used when the backup was created — otherwise restored SSH private keys cannot be decrypted
### Error Handling
| Error | Cause |
|---|---|
| "Failed to decrypt backup" | Wrong backup password |
| "Failed to parse backup" | Corrupt or invalid backup file |
| "Failed to import" | Database error during restore |
## Backup Security
- Backups are encrypted with AES-256-GCM using a key derived from SHA-256 of the backup password
- The encrypted blob is a single binary file (not JSON)
- Without the correct password, the backup cannot be read or modified
- Use strong, unique passwords for backups
- Store backup files and passwords separately
## Backup Strategy
### Recommended Approach
1. **Regular exports**: Export a backup weekly or after significant changes
2. **Secure storage**: Store `.kwbak` files in a separate, secure location
3. **Password management**: Store backup passwords in a password manager
4. **Test restores**: Periodically verify backups by restoring to a test instance
5. **Encryption key backup**: Keep a secure copy of `KEYWARDEN_ENCRYPTION_KEY`
### Docker Volume Backup
In addition to the application-level backup, you can also back up the Docker volume directly:
```bash
# Stop the container
docker compose down
# Backup the volume
docker run --rm -v keywarden_keywarden_data:/data -v $(pwd):/backup \
alpine tar czf /backup/keywarden-volume-backup.tar.gz /data
# Start the container
docker compose up -d
```
This captures the raw SQLite database file and all data files. Note that this backup is **not encrypted** — protect it accordingly.
+151
View File
@@ -0,0 +1,151 @@
# Contributing
Guide for developers who want to contribute to Keywarden or build from source.
## Prerequisites
- **Go 1.26+** with CGO enabled (required for SQLite)
- **GCC / C compiler** (required by `go-sqlite3`)
- **Git** for version control
### Platform-Specific Requirements
**Linux (Debian/Ubuntu):**
```bash
sudo apt install gcc sqlite3 libsqlite3-dev
```
**Alpine Linux:**
```bash
apk add gcc musl-dev sqlite-dev
```
**macOS:**
```bash
xcode-select --install
```
**Windows:**
Install [TDM-GCC](https://jmeubank.github.io/tdm-gcc/) or MinGW-w64, then set `CGO_ENABLED=1`.
## Building from Source
```bash
# Clone the repository
git clone https://git.techniverse.net/scriptos/keywarden.git
cd keywarden
# Download dependencies
go mod download
# Build
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
# Run
./keywarden
```
### Docker Build
```bash
docker compose build
docker compose up -d
```
## Project Structure
```
keywarden/
├── cmd/keywarden/main.go # Application entry point
├── internal/
│ ├── audit/audit.go # Audit logging service (action constants, logging, queries)
│ ├── auth/auth.go # Authentication service (login, register, MFA, password policy,
│ │ # account lockout, invitations, settings)
│ ├── config/config.go # Environment-based configuration loader
│ ├── cron/cron.go # Cron scheduler (temporary access jobs, scheduling logic)
│ ├── database/
│ │ ├── database.go # SQLite connection, migrations, schema
│ │ └── backup.go # Encrypted backup export/import
│ ├── deploy/deploy.go # SSH key deployment (deploy, remove, user management,
│ │ # sudo, disable, delete system users)
│ ├── encryption/encryption.go # AES-256-GCM encryption/decryption
│ ├── handlers/handlers.go # All HTTP handlers, routing, middleware, templates
│ ├── keys/keys.go # SSH key management, system master key
│ ├── logging/logging.go # Structured logging with levels, request logger
│ ├── mail/mail.go # SMTP email service (notifications, invitations, templates)
│ ├── models/models.go # Data models (User, SSHKey, Server, CronJob, etc.)
│ ├── security/
│ │ ├── csrf.go # CSRF double-submit cookie middleware
│ │ ├── headers.go # Security headers middleware (CSP, X-Frame-Options, etc.)
│ │ ├── proxy.go # Trusted proxy IP extraction
│ │ ├── ratelimit.go # IP-based rate limiting middleware
│ │ └── sizelimit.go # Request body size limit middleware
│ ├── servers/servers.go # Server and group management, access assignments
│ └── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
├── web/
│ ├── embed.go # Go embed directives
│ ├── static/ # CSS, JS, fonts (Tabler UI)
│ └── templates/ # HTML templates
├── docs/ # Documentation
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose configuration
├── go.mod # Go module definition
└── LICENSE # AGPL-3.0-or-later
```
## Architecture Principles
- **Single binary**: All assets (templates, CSS, JS) are embedded via Go's `embed` package
- **No external database**: SQLite with WAL mode, embedded in the binary
- **Standard library HTTP**: No web framework — uses `net/http` directly
- **Service pattern**: Each domain has its own service struct with a database dependency
- **Handler pattern**: One large handler file with all routes and template rendering
- **Middleware chain**: Security features implemented as composable HTTP middleware
## Key Design Decisions
### Roles
Three roles: `owner`, `admin`, `user`. The owner is created on first startup. Admins are created by the owner. Users can be created by admins, owner or via invitations.
### Master Key
A single system-wide Ed25519 SSH key pair is used for all server connections. This simplifies deployment: only one public key needs to be added to target servers.
### Access Assignments
Instead of ad-hoc key deployment, access assignments provide a declarative model. The desired state is stored in the database and synced to servers on demand.
### Cron Jobs
Temporary access is implemented as cron jobs that deploy keys on a schedule and remove them after a timeout using background timers.
## Running Tests
```bash
go test ./...
```
Test files are co-located with their packages (e.g., `auth_test.go`, `config_test.go`, `encryption_test.go`).
## Dependencies
| Module | Purpose |
|---|---|
| `github.com/mattn/go-sqlite3` | SQLite3 driver (CGO) |
| `golang.org/x/crypto` | bcrypt, SSH key operations |
| `github.com/cloudflare/circl` | Ed448 key support |
## Code Style
- Use `gofmt` for formatting
- Follow standard Go conventions
- Error messages should be lowercase
- Log messages use the structured logging package (`logging.Info`, `logging.Debug`, etc.)
## License
All contributions must be compatible with the [AGPL-3.0-or-later](../LICENSE) license.
Copyright (C) 2026 Patrick Asmus (scriptos)
+209
View File
@@ -0,0 +1,209 @@
# Installation & Deployment
This guide covers production deployment of Keywarden using Docker.
## Docker Deployment
Keywarden is designed as a single-container application with an embedded SQLite database. No external database server is required.
### Docker Image
Build from source or use the pre-built image:
```bash
# Build from source
docker compose build
# Or build manually
docker build -t keywarden .
```
### Multi-Stage Build
The Dockerfile uses a two-stage build:
1. **Builder stage** (`golang:1.26-alpine`): Compiles the Go binary with CGO (required for SQLite)
2. **Runtime stage** (`alpine:3.21`): Minimal image with only the compiled binary and runtime dependencies (`ca-certificates`, `sqlite-libs`, `tzdata`, `curl`)
The runtime container runs as a non-root user (`keywarden`).
### Docker Compose
A complete `docker-compose.yml`:
```yaml
services:
keywarden:
build: .
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
env_file:
- .env
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${KEYWARDEN_PORT:-8080}/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes:
keywarden_data:
driver: local
```
### Environment File (.env)
Create a `.env` file alongside `docker-compose.yml`:
```env
# Security (REQUIRED - change these!)
KEYWARDEN_SESSION_KEY=generate-a-random-string-of-at-least-32-chars
KEYWARDEN_ENCRYPTION_KEY=generate-another-random-string-32-chars
# Application
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Initial admin (only used on first startup)
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
# HTTPS / Reverse Proxy
KEYWARDEN_BASE_URL=https://keywarden.example.com
KEYWARDEN_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
KEYWARDEN_SECURE_COOKIES=true
# Rate Limiting
KEYWARDEN_RATE_LIMIT_LOGIN=10
KEYWARDEN_MAX_REQUEST_SIZE=10485760
# Email (optional)
KEYWARDEN_SMTP_HOST=smtp.example.com
KEYWARDEN_SMTP_PORT=587
KEYWARDEN_SMTP_USER=keywarden@example.com
KEYWARDEN_SMTP_PASSWORD=smtp-password
KEYWARDEN_SMTP_FROM=keywarden@example.com
KEYWARDEN_SMTP_TLS=true
```
See [Environment Variables](environment-variables.md) for a complete reference.
## Data Persistence
All persistent data is stored in the `/data` volume:
| Path | Content |
|---|---|
| `/data/keywarden.db` | SQLite database (users, keys, servers, settings, audit log) |
| `/data/keys/` | Reserved for future use |
| `/data/master/` | Reserved for future use |
| `/data/avatars/` | User profile pictures |
> **Important:** The SQLite database contains encrypted private keys. Back up the `/data` volume regularly. See [Backup & Restore](backup-restore.md).
## Reverse Proxy Setup
For production use, place Keywarden behind a reverse proxy with TLS termination.
### Nginx
```nginx
server {
listen 443 ssl http2;
server_name keywarden.example.com;
ssl_certificate /etc/ssl/certs/keywarden.crt;
ssl_certificate_key /etc/ssl/private/keywarden.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Recommended limits
client_max_body_size 10m;
}
}
```
### Caddy
```caddyfile
keywarden.example.com {
reverse_proxy localhost:8080
}
```
### Traefik
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.keywarden.rule=Host(`keywarden.example.com`)"
- "traefik.http.routers.keywarden.tls=true"
- "traefik.http.services.keywarden.loadbalancer.server.port=8080"
```
### Important Notes for Reverse Proxy
1. **Set `KEYWARDEN_BASE_URL`**: Required for correct email links and cookie configuration
2. **Set `KEYWARDEN_TRUSTED_PROXIES`**: Configure the CIDR range of your reverse proxy so Keywarden can extract the real client IP from `X-Forwarded-For` headers
3. **Set `KEYWARDEN_SECURE_COOKIES=true`**: Enable secure cookie flag when using HTTPS (auto-derived from `KEYWARDEN_BASE_URL` if the URL starts with `https://`)
## Health Check
Keywarden provides a health check endpoint at `/api/health` that returns JSON:
```json
{
"status": "healthy",
"uptime": "2d 5h 30m",
"uptime_seconds": 194400,
"checks": {
"database": {
"status": "ok"
}
}
}
```
The Docker HEALTHCHECK is configured automatically in the Dockerfile.
## Updating
To update Keywarden:
```bash
# Pull latest changes (if building from source)
git pull
# Rebuild and restart
docker compose down
docker compose build --no-cache
docker compose up -d
```
Database migrations run automatically on startup. No manual migration steps are required.
## System Master Key
On first startup, Keywarden generates an **Ed25519 system master key**. This key is used for all SSH connections to managed servers (deploying keys, creating users, etc.).
The public key is displayed in:
- The startup log output
- The Admin Settings page (owner only)
You must deploy this public key to every server you want to manage:
```bash
# On each target server
echo "<master-public-key>" >> /root/.ssh/authorized_keys
```
The master key can be regenerated from the Admin Settings page if needed (owner only). After regeneration, redeploy the new public key to all servers.
+110
View File
@@ -0,0 +1,110 @@
# Email Configuration
Keywarden supports SMTP-based email for login notifications and user invitations. Email is optional — all core functionality works without it.
## Enabling Email
Set the `KEYWARDEN_SMTP_HOST` environment variable to enable email:
```env
KEYWARDEN_SMTP_HOST=smtp.example.com
KEYWARDEN_SMTP_PORT=587
KEYWARDEN_SMTP_USER=keywarden@example.com
KEYWARDEN_SMTP_PASSWORD=your-password
KEYWARDEN_SMTP_FROM=keywarden@example.com
KEYWARDEN_SMTP_TLS=true
```
If `KEYWARDEN_SMTP_HOST` is empty, email is completely disabled and all email-related features are hidden from the UI.
## TLS Configuration
| Port | Mode | Setting |
|---|---|---|
| `587` | STARTTLS | `KEYWARDEN_SMTP_TLS=true` (default) |
| `465` | Implicit TLS | `KEYWARDEN_SMTP_TLS=true` + `KEYWARDEN_SMTP_PORT=465` |
| `25` | Unencrypted | `KEYWARDEN_SMTP_TLS=false` (not recommended) |
- Port **587** uses STARTTLS (upgrade from plaintext to TLS after connection)
- Port **465** uses implicit TLS (TLS from the start)
- Minimum TLS version: TLS 1.2
## Email Features
### Login Notifications
Users can individually enable login notification emails in their account settings. When enabled, each successful login triggers an email containing:
- Username
- Client IP address
- Timestamp
- User agent (browser information)
Emails are sent asynchronously in a background goroutine to avoid slowing down the login flow.
### User Invitations
When an admin creates a new user, they can choose to send an **invitation email** instead of setting a password manually. The invitation email contains:
- The username
- A secure one-time link (`/invite/{token}`)
- Expiration time (48 hours)
The invitation token is a 32-byte cryptographic random value, base32-encoded. Once used, the token is marked as consumed and cannot be reused.
### Test Email
The owner can send a test email from the Admin Settings page to verify that SMTP configuration is working correctly.
## Email Templates
All emails are sent as **multipart/alternative** (both plain text and HTML). The HTML versions use responsive, inline-styled templates.
### Login Notification Email
- **Subject**: `Keywarden: Login notification for {username}`
- **Content**: IP address, timestamp, user agent
- **Trigger**: Successful login when the user has notifications enabled
### Invitation Email
- **Subject**: `Keywarden: You have been invited {username}`
- **Content**: Username, registration link, expiration time
- **Trigger**: Admin creates a user with "Send Invitation" enabled
### Test Email
- **Subject**: `Keywarden: SMTP Test Email`
- **Content**: Confirmation that SMTP is working
## Base URL Requirement
For invitation emails to contain the correct link, set the `KEYWARDEN_BASE_URL` environment variable:
```env
KEYWARDEN_BASE_URL=https://keywarden.example.com
```
If not set, invitation links use relative paths which may not work in email clients.
## Troubleshooting
### Email Not Sent
1. Check that `KEYWARDEN_SMTP_HOST` is set
2. Verify SMTP credentials
3. Check the application logs for SMTP errors (`KEYWARDEN_LOG_LEVEL=DEBUG` for details)
4. Try sending a test email from Admin Settings
5. Verify network connectivity from the container to the SMTP server
### TLS Errors
- Ensure the SMTP server supports TLS 1.2+
- For self-signed certificates, the default TLS configuration may reject them
- Try different port/TLS combinations
### Authentication Failures
- Verify username and password
- Some providers require app-specific passwords (e.g., Gmail, Microsoft 365)
- Check if the SMTP server requires a specific authentication mechanism
+115
View File
@@ -0,0 +1,115 @@
# Environment Variables
Complete reference of all configuration options for Keywarden. All settings are read from environment variables at startup.
## Core Settings
| Variable | Default | Description |
|---|---|---|
| `KEYWARDEN_PORT` | `8080` | HTTP server listen port |
| `KEYWARDEN_DB_PATH` | `./data/keywarden.db` | Path to the SQLite database file |
| `KEYWARDEN_DATA_DIR` | `./data` | Base directory for persistent data |
| `KEYWARDEN_KEYS_DIR` | `./data/keys` | Directory for key storage (reserved) |
| `KEYWARDEN_MASTER_DIR` | `./data/master` | Directory for master key storage (reserved) |
| `KEYWARDEN_LOG_LEVEL` | `INFO` | Log level: `ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE` |
## Security
| Variable | Default | Description |
|---|---|---|
| `KEYWARDEN_SESSION_KEY` | `change-me-in-production-please` | Secret key for session cookie signing. **Change this!** |
| `KEYWARDEN_ENCRYPTION_KEY` | `change-me-encryption-key-32chars` | Encryption key for SSH private keys (AES-256). **Change this!** |
| `KEYWARDEN_BASE_URL` | _(empty)_ | External base URL (e.g., `https://keywarden.example.com`). Used for email links and cookie configuration. Auto-derives `KEYWARDEN_SECURE_COOKIES` from scheme. |
| `KEYWARDEN_TRUSTED_PROXIES` | _(empty)_ | Comma-separated CIDR ranges or IPs of trusted reverse proxies (e.g., `10.0.0.0/8,172.16.0.0/12`). When set, `X-Forwarded-For` is only honored from these networks. |
| `KEYWARDEN_SECURE_COOKIES` | _(auto)_ | Set `true` to enable `Secure` flag on cookies. Auto-derived from `KEYWARDEN_BASE_URL` if it starts with `https://`. |
| `KEYWARDEN_RATE_LIMIT_LOGIN` | `10` | Maximum login POST attempts per IP per minute. Set to `0` to disable. |
| `KEYWARDEN_MAX_REQUEST_SIZE` | `10485760` | Maximum request body size in bytes (default: 10 MB). Set to `0` for no limit. |
## Initial Admin Account
These variables are only used on first startup when no users exist in the database:
| Variable | Default | Description |
|---|---|---|
| `KEYWARDEN_ADMIN_USER` | `admin` | Username for the initial owner account |
| `KEYWARDEN_ADMIN_EMAIL` | `admin@keywarden.local` | Email for the initial owner account |
The initial password is auto-generated (20 characters, alphanumeric) and printed to the startup log. It must be changed on first login.
## Email / SMTP
| Variable | Default | Description |
|---|---|---|
| `KEYWARDEN_SMTP_HOST` | _(empty)_ | SMTP server hostname. Email is disabled if not set. |
| `KEYWARDEN_SMTP_PORT` | `587` | SMTP server port. Use `587` for STARTTLS or `465` for implicit TLS. |
| `KEYWARDEN_SMTP_USER` | _(empty)_ | SMTP authentication username |
| `KEYWARDEN_SMTP_PASSWORD` | _(empty)_ | SMTP authentication password |
| `KEYWARDEN_SMTP_FROM` | `keywarden@localhost` | Sender email address (`From` header) |
| `KEYWARDEN_SMTP_TLS` | `true` | Enable TLS for SMTP connections. Set `false` for unencrypted SMTP (not recommended). |
## Docker-Specific Defaults
When running in the Docker container, these defaults are set in the Dockerfile:
| Variable | Docker Default |
|---|---|
| `KEYWARDEN_PORT` | `8080` |
| `KEYWARDEN_DB_PATH` | `/data/keywarden.db` |
| `KEYWARDEN_DATA_DIR` | `/data` |
| `KEYWARDEN_KEYS_DIR` | `/data/keys` |
| `KEYWARDEN_MASTER_DIR` | `/data/master` |
## Example .env File
```env
# ──────────────────────────────────────────────
# Keywarden Configuration
# ──────────────────────────────────────────────
# Security (REQUIRED - change these!)
KEYWARDEN_SESSION_KEY=Rj9kL2mN4pQ8sT1vW3xY5zA7bC0dF6gH
KEYWARDEN_ENCRYPTION_KEY=mX9nP2qR4sT6uV8wY0zA1bC3dE5fG7hI
# Application
KEYWARDEN_PORT=8080
KEYWARDEN_LOG_LEVEL=INFO
# Initial admin (only used on first startup)
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
# Reverse proxy / HTTPS
KEYWARDEN_BASE_URL=https://keywarden.example.com
KEYWARDEN_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Rate limiting
KEYWARDEN_RATE_LIMIT_LOGIN=10
KEYWARDEN_MAX_REQUEST_SIZE=10485760
# Email (optional)
KEYWARDEN_SMTP_HOST=smtp.example.com
KEYWARDEN_SMTP_PORT=587
KEYWARDEN_SMTP_USER=keywarden@example.com
KEYWARDEN_SMTP_PASSWORD=your-smtp-password
KEYWARDEN_SMTP_FROM=keywarden@example.com
KEYWARDEN_SMTP_TLS=true
```
## Application Settings (Database)
In addition to environment variables, the following settings are configured through the web UI (Admin Settings page, owner only) and stored in the database:
| Setting Key | Default | Description |
|---|---|---|
| `app_name` | `Keywarden` | Application display name in the UI |
| `default_key_type` | `ed25519` | Default key type for generation |
| `default_key_bits` | `256` | Default key size |
| `session_timeout` | `60` | Session inactivity timeout in minutes |
| `pw_min_length` | `8` | Password minimum length |
| `pw_require_upper` | `true` | Require uppercase letter |
| `pw_require_lower` | `true` | Require lowercase letter |
| `pw_require_digit` | `true` | Require digit |
| `pw_require_special` | `false` | Require special character |
| `lockout_attempts` | `5` | Failed login attempts before lockout (0 = disabled) |
| `lockout_duration` | `15` | Lockout duration in minutes |
| `mfa_required` | `false` | Enforce MFA for all users |
+122
View File
@@ -0,0 +1,122 @@
# Quick Start Guide
Get Keywarden running in under 5 minutes using Docker Compose.
## Prerequisites
- Docker and Docker Compose installed
- A Linux host (or any system that runs Docker)
## 1. Create Project Directory
```bash
mkdir keywarden && cd keywarden
```
## 2. Create Environment File
Create a `.env` file with at minimum these settings:
```env
# REQUIRED: Change these for security!
KEYWARDEN_SESSION_KEY=your-random-session-key-at-least-32-characters
KEYWARDEN_ENCRYPTION_KEY=your-random-encryption-key-at-least-32-chars
# Optional: Admin credentials (defaults: admin / auto-generated password)
KEYWARDEN_ADMIN_USER=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com
# Optional: Port (default: 8080)
KEYWARDEN_PORT=8080
```
> **Important:** The `KEYWARDEN_ENCRYPTION_KEY` is used to encrypt all private keys at rest. If you lose this key, stored private keys cannot be decrypted. Keep it safe!
## 3. Create docker-compose.yml
```yaml
services:
keywarden:
image: git.techniverse.net/scriptos/keywarden:latest
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
env_file:
- .env
volumes:
keywarden_data:
driver: local
```
Or, to build from source:
```yaml
services:
keywarden:
build: .
container_name: keywarden
restart: unless-stopped
ports:
- "${KEYWARDEN_PORT:-8080}:${KEYWARDEN_PORT:-8080}"
volumes:
- keywarden_data:/data
env_file:
- .env
volumes:
keywarden_data:
driver: local
```
## 4. Start Keywarden
```bash
docker compose up -d
```
## 5. Get the Initial Password
On first startup, Keywarden creates an owner account and generates a secure random password. Check the logs:
```bash
docker compose logs keywarden
```
Look for output like:
```
════════════════════════════════════════════════════════════
Initial owner account created
Username: admin
Password: AbCdEf1234567890XyZw
Please change this password after first login!
════════════════════════════════════════════════════════════
```
## 6. Log In
Open your browser and navigate to `http://your-host:8080`, then log in with the credentials from the logs.
You will be prompted to change the initial password on first login.
## 7. Deploy the Master Key
After login, Keywarden displays the **system master key** (an Ed25519 public key). This key must be placed in the `~/.ssh/authorized_keys` file of the admin/root user on every server you want to manage.
The master key is shown on the **Admin Settings** page and in the startup logs.
```bash
# On each target server, as root:
echo "ssh-ed25519 AAAA... keywarden-system-master" >> ~/.ssh/authorized_keys
```
## What's Next?
- [Full Deployment Guide](deployment.md) — Production setup with HTTPS and reverse proxy
- [User Guide](user-guide.md) — How to manage SSH keys
- [Admin Guide](admin-guide.md) — How to manage servers and access assignments
- [Environment Variables](environment-variables.md) — All configuration options
+122
View File
@@ -0,0 +1,122 @@
# Roles & Permissions
Keywarden uses a three-tier role system: **Owner**, **Admin**, and **User**. Each role inherits all permissions of the role below it.
## Role Hierarchy
```
Owner → Admin → User
```
## Role Comparison
| Capability | User | Admin | Owner |
|---|:---:|:---:|:---:|
| **SSH Keys** | | | |
| Generate SSH keys | ✅ | ✅ | ✅ |
| Import SSH keys | ✅ | ✅ | ✅ |
| View own keys | ✅ | ✅ | ✅ |
| Download own keys | ✅ | ✅ | ✅ |
| Delete own keys | ✅ | ✅ | ✅ |
| View all users' keys | ❌ | ✅ | ✅ |
| Delete any user's keys | ❌ | ✅ | ✅ |
| **Account Settings** | | | |
| Change own password | ✅ | ✅ | ✅ |
| Change theme | ✅ | ✅ | ✅ |
| Enable/disable MFA | ✅ | ✅ | ✅ |
| Upload avatar | ✅ | ✅ | ✅ |
| Toggle email notifications | ✅ | ✅ | ✅ |
| **Access** | | | |
| View own access assignments | ✅ | ✅ | ✅ |
| View own audit log | ✅ | ✅ | ✅ |
| **Server Management** | | | |
| Add/edit/delete servers | ❌ | ✅ | ✅ |
| Add/edit/delete server groups | ❌ | ✅ | ✅ |
| Test server connectivity | ❌ | ✅ | ✅ |
| **Deployments** | | | |
| Manual key deployment | ❌ | ✅ | ✅ |
| Group deployment | ❌ | ✅ | ✅ |
| **Access Assignments** | | | |
| Create/edit/delete assignments | ❌ | ✅ | ✅ |
| Sync assignments | ❌ | ✅ | ✅ |
| **Temporary Access (Cron)** | | | |
| Create/edit/delete cron jobs | ❌ | ✅ | ✅ |
| Pause/resume cron jobs | ❌ | ✅ | ✅ |
| **User Management** | | | |
| Create/edit/delete users | ❌ | ✅ | ✅ |
| Unlock locked accounts | ❌ | ✅ | ✅ |
| Force password change | ❌ | ✅ | ✅ |
| Send user invitations | ❌ | ✅ | ✅ |
| **System** | | | |
| View system information | ❌ | ✅ | ✅ |
| View full audit log | ❌ | ✅ | ✅ |
| **Administration** | | | |
| Application settings | ❌ | ❌ | ✅ |
| Security settings (password policy, MFA enforcement) | ❌ | ❌ | ✅ |
| Regenerate master key | ❌ | ❌ | ✅ |
| Backup / Restore | ❌ | ❌ | ✅ |
| Send test email | ❌ | ❌ | ✅ |
## Role Details
### User
The **User** role is the default role for new accounts. Users can:
- Manage their own SSH keys (generate, import, download, delete)
- View their own access assignments (read-only)
- Manage their account settings (password, theme, MFA, avatar, email notifications)
- View their own audit log entries
Users **cannot** manage servers, deploy keys, create access assignments, or view other users' data.
### Admin
The **Admin** role has full operational access. In addition to all User permissions, admins can:
- Manage servers and server groups (add, edit, delete, test connectivity)
- Deploy SSH keys to servers (manual and group deployments)
- Create and manage access assignments (including sync and cleanup)
- Create and manage cron jobs for temporary access
- Manage users (create, edit, delete, unlock, force password change, send invitations)
- View system information
- View the complete audit log (excluding owner entries)
Admins **cannot** access the Admin Settings page, regenerate the master key, manage backups, or modify security policies.
### Owner
The **Owner** role has unrestricted access. In addition to all Admin permissions, the owner can:
- Access the Admin Settings page
- Configure application settings (app name, session timeout, default key type)
- Configure security settings (password policy, account lockout, MFA enforcement)
- View and regenerate the system master key
- Export and import encrypted database backups
- Send test emails
- View all audit log entries (including owner actions)
#### Owner Protections
- The last owner account cannot be deleted
- The owner can always access Admin Settings, even when MFA enforcement would otherwise redirect them (to prevent lockout)
- On first startup, the initial account is always created with the `owner` role
- If no owner exists (e.g., after a migration from an older version), the first admin is automatically promoted to owner
## Audit Log Visibility
The audit log has role-based filtering:
| Viewer | Sees |
|---|---|
| User | Own actions only |
| Admin | All actions except those from owner accounts |
| Owner | All actions from all users |
## Initial Setup
On first startup, Keywarden creates a single **owner** account. The owner should then:
1. Change the initial password
2. (Optional) Create additional admin accounts
3. (Optional) Create regular user accounts or send invitations
+211
View File
@@ -0,0 +1,211 @@
# Security
This document describes Keywarden's security features, architecture, and best practices.
## Authentication
### Password Authentication
- Passwords are hashed with **bcrypt** at the default cost factor (10)
- Password policy is configurable by the owner (see below)
- Accounts are locked after configurable failed login attempts
### Password Policy
The owner can configure password requirements in Admin Settings:
| Setting | Default | Description |
|---|---|---|
| Minimum length | 8 | Minimum number of characters |
| Require uppercase | Yes | At least one uppercase letter (A-Z) |
| Require lowercase | Yes | At least one lowercase letter (a-z) |
| Require digit | Yes | At least one number (0-9) |
| Require special character | No | At least one non-alphanumeric character |
The policy is enforced on:
- User registration (invitation acceptance)
- Password changes (manual and forced)
- Admin password resets
### Account Lockout
After a configurable number of failed login attempts (default: 5), an account is locked for a configurable duration (default: 15 minutes).
| Setting | Default |
|---|---|
| Lockout threshold | 5 attempts |
| Lockout duration | 15 minutes |
Setting lockout attempts to 0 disables the lockout feature.
Admins can manually unlock accounts from the user management page.
### Forced Password Change
Admins can flag any user to require a password change. The user will be redirected to the password change page on every request until they set a new password. This is automatically enabled for:
- Newly created accounts
- Accounts where an admin reset the password
## Two-Factor Authentication (MFA)
Keywarden supports TOTP (Time-based One-Time Password) for MFA, compatible with:
- Google Authenticator
- Authy
- Microsoft Authenticator
- Any RFC 6238 compliant app
### Implementation Details
- **Algorithm**: HMAC-SHA1
- **Code length**: 6 digits
- **Period**: 30 seconds
- **Clock tolerance**: ±1 time step (allows 30 seconds of clock skew)
- **Secret generation**: 20 bytes of cryptographic random data
- **Secret encoding**: Base32 (unpadded)
### MFA Enforcement
The owner can enable system-wide MFA enforcement in Admin Settings. When enabled:
- Users without MFA are redirected to the MFA setup page on every request
- Users cannot disable MFA while enforcement is active
- The owner can always access Admin Settings (even without MFA) to prevent lockout
## Encryption
### Private Key Encryption (At Rest)
All SSH private keys stored in the database are encrypted with **AES-256-GCM**:
1. The `KEYWARDEN_ENCRYPTION_KEY` is hashed with SHA-256 → 32-byte AES key
2. A random 12-byte nonce is generated for each encryption operation
3. Plaintext is encrypted with AES-256-GCM (provides confidentiality + integrity)
4. Result: `nonce || ciphertext || GCM-tag` → base64-encoded → stored in DB
> **Critical:** If `KEYWARDEN_ENCRYPTION_KEY` is changed or lost, all stored private keys become permanently inaccessible.
### Backup Encryption
Database exports are encrypted with a user-provided password using the same AES-256-GCM scheme. The password is required for both export and import.
## CSRF Protection
Keywarden implements the **Double-Submit Cookie** pattern:
1. A `_csrf` cookie is set on every request (32 bytes, hex-encoded, 64 chars)
2. On state-changing methods (POST, PUT, DELETE, PATCH), the request must include a matching token as:
- A form field named `_csrf`, or
- An `X-CSRF-Token` request header
3. Tokens are compared using constant-time comparison to prevent timing attacks
The cookie is **not** HttpOnly (JavaScript must read it to inject into forms), but is:
- `SameSite=Strict`
- `Secure` when HTTPS is enabled
- Expires after 24 hours
## Security Headers
Every response includes:
| Header | Value | Purpose |
|---|---|---|
| `X-Frame-Options` | `DENY` | Prevents clickjacking |
| `X-Content-Type-Options` | `nosniff` | Prevents MIME sniffing |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls Referer leakage |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | Disables unused APIs |
| `Content-Security-Policy` | See below | Restricts resource loading |
| `X-Permitted-Cross-Domain-Policies` | `none` | Blocks cross-domain policy files |
| `Cache-Control` | `no-store, no-cache, must-revalidate, private` | Prevents caching of authenticated pages |
### Content Security Policy
```
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
frame-ancestors 'none';
form-action 'self';
base-uri 'self'
```
## Rate Limiting
Login endpoints (`POST /login`, `POST /login/mfa`) are rate-limited per IP address.
- **Default limit**: 10 attempts per IP per minute
- **Algorithm**: Fixed-window counter
- **Response when exceeded**: HTTP 429 (Too Many Requests)
- **Configuration**: `KEYWARDEN_RATE_LIMIT_LOGIN` (0 = disabled)
A background goroutine cleans up expired rate limit entries every 5 minutes.
## Request Size Limiting
Request bodies are limited to prevent denial-of-service via large uploads.
- **Default limit**: 10 MB (`10485760` bytes)
- **Response when exceeded**: HTTP 413 (Request Entity Too Large)
- **Configuration**: `KEYWARDEN_MAX_REQUEST_SIZE` (0 = no limit)
## Trusted Proxy Configuration
When Keywarden runs behind a reverse proxy, the real client IP must be extracted from `X-Forwarded-For` or `X-Real-IP` headers. However, these headers can be spoofed by clients.
### Strict Mode (Recommended)
Set `KEYWARDEN_TRUSTED_PROXIES` to the CIDR range(s) of your reverse proxy:
```env
KEYWARDEN_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
```
In strict mode:
- Proxy headers are only trusted when the direct TCP peer is from a trusted network
- `X-Forwarded-For` is walked right-to-left, and the first non-trusted IP is used
- This prevents client-side IP spoofing
### Legacy Mode
If `KEYWARDEN_TRUSTED_PROXIES` is not set, Keywarden trusts all proxy headers unconditionally. A warning is logged at startup:
```
WARN: KEYWARDEN_TRUSTED_PROXIES not set proxy headers (X-Forwarded-For) are trusted unconditionally
```
## Session Security
- Session tokens: 32 bytes, cryptographically random, hex-encoded
- Cookie name: `keywarden_session`
- Cookie flags:
- `HttpOnly` — Not accessible via JavaScript
- `SameSite=Lax` — Prevents CSRF from external sites
- `Secure` — Only over HTTPS (when enabled)
- `MaxAge=86400` — 24 hours
- Sessions stored in-memory (not persisted across restarts)
- Configurable inactivity timeout (default: 60 minutes)
- Background cleanup runs every minute
## SSH Connection Security
When deploying keys to servers, Keywarden:
- Uses the system master key (Ed25519) for SSH authentication
- Connects with a 10-second timeout
- **Does not verify host keys** (`InsecureIgnoreHostKey`) — this is a known limitation
> **Note:** Host key verification is not yet implemented. This means Keywarden is susceptible to man-in-the-middle attacks during SSH connections. Only use Keywarden in trusted network environments.
## Best Practices
1. **Change the default secrets**: Set unique values for `KEYWARDEN_SESSION_KEY` and `KEYWARDEN_ENCRYPTION_KEY`
2. **Use HTTPS**: Run behind a reverse proxy with TLS termination
3. **Configure trusted proxies**: Set `KEYWARDEN_TRUSTED_PROXIES` for accurate IP logging
4. **Enable secure cookies**: Set `KEYWARDEN_SECURE_COOKIES=true` (auto-derived from HTTPS base URL)
5. **Enable MFA enforcement**: Require all users to use two-factor authentication
6. **Use strong passwords**: Configure a strict password policy
7. **Regular backups**: Export encrypted backups regularly
8. **Network isolation**: Restrict access to Keywarden and managed servers to trusted networks
9. **Keep the encryption key safe**: Back up `KEYWARDEN_ENCRYPTION_KEY` securely — losing it means losing all private keys
10. **Monitor the audit log**: Review login activity and deployment actions regularly
+215
View File
@@ -0,0 +1,215 @@
# Troubleshooting
Common issues and solutions for Keywarden.
## Startup Issues
### "Failed to initialize database"
**Cause**: SQLite database file cannot be created or accessed.
**Solutions**:
- Check that the `/data` directory exists and is writable
- Verify the `KEYWARDEN_DB_PATH` environment variable
- In Docker: ensure the volume is correctly mounted and the `keywarden` user has write access
### "Failed to create directory"
**Cause**: Data directories (`/data`, `/data/keys`, `/data/master`) cannot be created.
**Solutions**:
- Check filesystem permissions
- In Docker: the container runs as user `keywarden` — ensure the volume has correct ownership
### Initial Password Not Showing
**Cause**: The initial owner password is only printed on the **first startup** when no users exist.
**Solutions**:
- Check the very first startup logs: `docker compose logs keywarden`
- If you missed the password, delete the database and restart to trigger a fresh setup:
```bash
docker compose down
docker volume rm keywarden_keywarden_data
docker compose up -d
docker compose logs keywarden
```
## Login Issues
### "Invalid username or password"
- Verify the username (case-sensitive)
- Check for typos in the password
- If this is the initial login, find the auto-generated password in the startup logs
### "Account is temporarily locked"
**Cause**: Too many failed login attempts.
**Solutions**:
- Wait for the lockout period to expire (default: 15 minutes)
- Ask an administrator to unlock the account from the user management page
- If you're the only owner: wait for the lockout to expire, or delete and recreate the database
### MFA Code Invalid
- Verify your authenticator app has the correct time (TOTP is time-based)
- Allow ±30 seconds of clock skew
- If you lost your MFA device, an admin with database access will need to manually disable MFA
### "Forbidden invalid or missing CSRF token"
**Cause**: CSRF token mismatch. This can happen if:
- Your session expired and you submitted a form on a stale page
- Cookies are blocked by your browser
- A proxy is stripping or modifying cookies
**Solutions**:
- Refresh the page and try again
- Clear your browser cookies for the Keywarden domain
- Ensure cookies are not being blocked
## SSH Deployment Issues
### "System master key not available"
**Cause**: The system master key is missing or corrupted in the settings table.
**Solutions**:
- Check the startup logs for the master key output
- Navigate to Admin Settings and view the master key
- If corrupted, regenerate the master key (owner only)
### "Connection failed" / "Cannot reach server"
**Cause**: Keywarden cannot establish a TCP connection to the target server.
**Solutions**:
- Verify the server hostname and port
- Use the **Connection Test** feature to check TCP connectivity
- Ensure the Keywarden container can reach the server's network
- Check firewall rules on both sides
### "SSH authentication failed"
**Cause**: The system master key is not authorized on the target server.
**Solutions**:
1. Get the master public key from Admin Settings or startup logs
2. Add it to the target server:
```bash
echo "<master-public-key>" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
```
3. Ensure the server's SSH daemon accepts public key authentication
4. Use the **Auth Test** feature to verify
5. If using a non-root admin user on the target server, ensure that user has permissions to manage `authorized_keys` for other users
### "Failed to create system user"
**Cause**: The `useradd` command failed on the target server.
**Solutions**:
- Verify the server's admin user has sufficient privileges (root or sudo)
- Check if the username conflicts with an existing user
- Review the server's `/var/log/auth.log` for details
### "Failed to deploy key for user"
**Cause**: Key deployment to a specific system user failed.
**Solutions**:
- Verify the system user exists (or enable "Create User" in the assignment)
- Check directory permissions on the target server
- Ensure the admin user can write to other users' `.ssh` directories
## Email Issues
### "Email is not configured"
**Cause**: `KEYWARDEN_SMTP_HOST` is not set.
**Solution**: Configure SMTP settings in the `.env` file. See [Email Configuration](email.md).
### SMTP Connection Errors
- Verify the SMTP host, port, and credentials
- Check if the Docker container can reach the SMTP server
- Try different TLS settings (`KEYWARDEN_SMTP_TLS=true/false`)
- For port 465, ensure implicit TLS is supported by the server
- Check if the SMTP server requires app-specific passwords
### Invitation Emails Not Arriving
- Verify the recipient's email address
- Check spam/junk folders
- Review application logs for SMTP errors (`KEYWARDEN_LOG_LEVEL=DEBUG`)
- Verify `KEYWARDEN_BASE_URL` is set correctly (needed for the invitation link)
- Send a test email from Admin Settings to verify SMTP works
## Backup Issues
### "Failed to decrypt backup"
**Cause**: Wrong backup password.
**Solution**: Use the exact password that was provided during backup export.
### "Failed to parse backup"
**Cause**: The backup file is corrupt or not a valid `.kwbak` file.
**Solution**: Ensure the file was not modified or corrupted during transfer.
### SSH Keys Not Working After Restore
**Cause**: The `KEYWARDEN_ENCRYPTION_KEY` in the current environment doesn't match the one used when the backup was created.
**Solution**: Set `KEYWARDEN_ENCRYPTION_KEY` to the same value that was in use when the backup was created.
## Performance
### Slow Page Loads
- Check the log level — `TRACE` and `DEBUG` can be verbose
- SQLite WAL mode is enabled by default for better concurrent read performance
- The in-memory session store scales well for typical deployments
### High Memory Usage
- Sessions are stored in memory — many active sessions increase memory
- The session cleanup goroutine runs every minute to remove expired sessions
- Avatar images are served from disk, not stored in memory
## Logs
### Viewing Logs
```bash
# Docker
docker compose logs keywarden
docker compose logs -f keywarden # follow
# Log levels
KEYWARDEN_LOG_LEVEL=DEBUG # more detail
KEYWARDEN_LOG_LEVEL=TRACE # maximum verbosity
```
### Log Levels
| Level | Output |
|---|---|
| `ERROR` | Only errors |
| `WARN` | Errors + warnings |
| `INFO` | Errors + warnings + informational (default) |
| `DEBUG` | All of the above + debug details |
| `TRACE` | Maximum verbosity, including request/response details |
### Request Logging
Every HTTP request is logged with:
- Method, path, status code
- Response time
- Client IP address
- Username (if authenticated)
+153
View File
@@ -0,0 +1,153 @@
# User Guide
This guide covers everyday usage of Keywarden for all authenticated users. For administrative tasks (server management, access assignments, etc.), see the [Admin Guide](admin-guide.md).
## Logging In
Navigate to your Keywarden instance in a web browser and enter your username and password.
- If **MFA is enabled** on your account, you will be prompted for a TOTP code after entering your password.
- If your account is **locked** due to too many failed login attempts, wait for the lockout period to expire or ask an administrator to unlock it.
- If you were **invited via email**, use the invitation link to set your password before logging in.
## Dashboard
The dashboard provides an overview of your environment:
- **Key Count** — Number of SSH keys you own
- **Server Count** — Servers you have access to (admins/owners see all servers)
- **Group Count** — Server groups (admins/owners see all groups)
- **Assignment Count** — Access assignments related to you
- **Recent Keys** — Latest SSH keys
- **Recent Audit Log** — Your recent activity (admins see global activity)
- **Recent Deployments** — Latest key deployment results
## SSH Key Management
### Generating Keys
1. Navigate to **Keys****Generate Key**
2. Fill in the form:
- **Name**: A descriptive name for the key (e.g., "Production Deploy Key")
- **Key Type**: Choose between:
- **Ed25519** (recommended) — Fast, secure, compact. 256-bit.
- **RSA 2048** — Widely compatible
- **RSA 4096** — Maximum RSA security
- **Ed448** — 224-bit security level (experimental)
- **Comment**: Optional comment embedded in the public key
3. Click **Generate**
The private key is encrypted with AES-256-GCM and stored in the database. It never touches the filesystem in plaintext.
### Importing Keys
1. Navigate to **Keys****Import Key**
2. Enter a **name** for the key
3. Paste the **private key** (PEM format) into the text area
4. Click **Import**
Keywarden automatically detects the key type (RSA, Ed25519, Ed448) and extracts the public key and fingerprint.
### Viewing Keys
The **Keys** page lists all your SSH keys with:
- Name, type, key size
- SHA-256 fingerprint
- Creation date
Admins and owners see all keys in the system, grouped by owner.
### Downloading Keys
From the key list, you can download:
- **Public Key** — For deployment to servers
- **Private Key** — Decrypted and downloaded (use with caution)
### Deleting Keys
Click the delete button next to a key. This permanently removes both the public and encrypted private key from the database.
> **Note:** Deleting a key from Keywarden does **not** remove it from servers where it was previously deployed.
## My Access
Navigate to **My Access** to see all access assignments that grant you access to servers:
- **Target** — Server or server group
- **System User** — The Linux user account on the target server
- **SSH Key** — Which of your keys is deployed
- **Sudo** — Whether sudo privileges are granted
- **Status** — Current sync status (pending, synced, failed)
- **Initial Password** — If a system user was created for you, the initial password is shown here
This is a read-only view. Only administrators can create, modify, or delete access assignments.
## User Settings
Navigate to **Settings** to manage your account:
### Theme
Choose between:
- **Auto** — Follows your system/browser preference
- **Light** — Always light mode
- **Dark** — Always dark mode
### Password Change
Change your password. The new password must comply with the configured password policy (displayed on the form).
### Two-Factor Authentication (MFA)
#### Enabling MFA
1. Go to **Settings****Two-Factor Authentication**
2. Click **Enable MFA**
3. Scan the QR code with an authenticator app (Google Authenticator, Authy, etc.)
4. Enter the 6-digit code from your app to confirm
5. MFA is now active for your account
#### Disabling MFA
Click **Disable MFA** in settings. This is only available if the administrator has not enforced MFA system-wide.
> If MFA is enforced by the administrator, you **must** set it up before you can access any other page.
### Email Notifications
If email is configured, you can enable **Login Notifications**. Every time someone logs into your account, you'll receive an email with:
- IP address
- Timestamp
- User agent (browser)
### Profile Picture
Upload a profile picture (avatar) that is displayed next to your name in the navigation. Supported formats: JPEG, PNG, GIF, WebP. Maximum size is limited by the server's request size limit.
## Audit Log
Navigate to **Audit** to view the activity log:
- **Regular users** see their own activity
- **Admins** see activity from all non-owner users
- **Owners** see all activity
The audit log records every significant action including logins, key operations, deployments, settings changes, and administrative actions.
## Forced Password Change
If an administrator flags your account for a mandatory password change, you will be redirected to the password change page on every request until you set a new password. This typically happens:
- After your initial account creation
- If an admin resets your password
- If there's a security concern
## Invitation Flow
If you receive an invitation email:
1. Click the invitation link
2. You'll see a registration page with your pre-assigned username
3. Choose a password that meets the password policy
4. Confirm the password
5. Click **Set Password**
6. You can now log in with your username and new password
+11
View File
@@ -0,0 +1,11 @@
module git.techniverse.net/scriptos/keywarden
go 1.26.1
require (
github.com/cloudflare/circl v1.6.3
github.com/mattn/go-sqlite3 v1.14.38
golang.org/x/crypto v0.49.0
)
require golang.org/x/sys v0.42.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+281
View File
@@ -0,0 +1,281 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package audit
import (
"database/sql"
"fmt"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/logging"
"git.techniverse.net/scriptos/keywarden/internal/models"
)
// Action constants for audit logging
const (
// Authentication
ActionLoginSuccess = "login_success"
ActionLoginFailed = "login_failed"
ActionLogout = "logout"
ActionMFASuccess = "mfa_verified"
ActionMFAFailed = "mfa_failed"
ActionMFAEnabled = "mfa_enabled"
ActionMFADisabled = "mfa_disabled"
// SSH Keys
ActionKeyGenerated = "key_generated"
ActionKeyImported = "key_imported"
ActionKeyDeleted = "key_deleted"
ActionKeyDownload = "key_downloaded"
// Servers
ActionServerAdded = "server_added"
ActionServerUpdated = "server_updated"
ActionServerDeleted = "server_deleted"
ActionServerTest = "server_test"
ActionServerAuth = "server_auth_test"
// Server Groups
ActionGroupCreated = "group_created"
ActionGroupUpdated = "group_updated"
ActionGroupDeleted = "group_deleted"
ActionGroupServerAdded = "group_server_added"
ActionGroupServerRemoved = "group_server_removed"
ActionGroupDeploy = "group_deploy"
// Deployments
ActionDeploySuccess = "deploy_success"
ActionDeployFailed = "deploy_failed"
// User Management (admin)
ActionUserCreated = "user_created"
ActionUserUpdated = "user_updated"
ActionUserDeleted = "user_deleted"
// Settings
ActionSettingsChanged = "settings_changed"
ActionPasswordChanged = "password_changed"
ActionMasterKeyRegen = "masterkey_regenerated"
ActionMasterKeyRegenerated = "masterkey_regenerated"
ActionMasterKeyRegenFailed = "masterkey_regen_failed"
ActionAvatarChanged = "avatar_changed"
// Email
ActionEmailNotifyChanged = "email_notify_changed"
ActionEmailTestSent = "email_test_sent"
ActionEmailTestFailed = "email_test_failed"
ActionEmailLoginSent = "email_login_sent"
ActionEmailLoginFailed = "email_login_failed"
// Access Assignments
ActionAssignmentCreated = "assignment_created"
ActionAssignmentUpdated = "assignment_updated"
ActionAssignmentDeleted = "assignment_deleted"
ActionAssignmentSynced = "assignment_synced"
ActionAssignmentSyncFailed = "assignment_sync_failed"
ActionAssignmentKeyRemoved = "assignment_key_removed"
ActionAssignmentUserDeleted = "assignment_user_deleted"
ActionAssignmentCleanFailed = "assignment_cleanup_failed"
// Cron Jobs
ActionCronJobCreated = "cron_job_created"
ActionCronJobUpdated = "cron_job_updated"
ActionCronJobDeleted = "cron_job_deleted"
ActionCronJobPaused = "cron_job_paused"
ActionCronJobResumed = "cron_job_resumed"
ActionCronJobExecuted = "cron_job_executed"
ActionCronJobFailed = "cron_job_failed"
ActionCronJobKeyRemoved = "cron_job_key_removed"
// Account Security
ActionAccountLocked = "account_locked"
ActionAccountUnlocked = "account_unlocked"
ActionForcePasswordChange = "force_password_change"
ActionMFAEnforced = "mfa_enforced"
ActionPasswordPolicyChanged = "password_policy_changed"
// Backup & Restore
ActionBackupExported = "backup_exported"
ActionBackupExportFailed = "backup_export_failed"
ActionBackupImported = "backup_imported"
ActionBackupImportFailed = "backup_import_failed"
// Invitations
ActionInvitationSent = "invitation_sent"
ActionInvitationSendFailed = "invitation_send_failed"
ActionInvitationAccepted = "invitation_accepted"
ActionInvitationFailed = "invitation_failed"
)
// AuditEntry extends AuditLog with the username for display
type AuditEntry struct {
models.AuditLog
Username string
}
// Service handles audit log operations
type Service struct {
db *database.DB
}
// NewService creates a new audit service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// Log records an audit event. Errors are logged but never returned to avoid
// disrupting the main application flow.
func (s *Service) Log(userID int64, action, details, ipAddress string) {
var uid interface{}
if userID == 0 {
uid = sql.NullInt64{Valid: false}
} else {
uid = userID
}
logging.Debug("Audit: action=%s user_id=%d ip=%s details=%s", action, userID, ipAddress, details)
_, err := s.db.Exec(
`INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)`,
uid, action, details, ipAddress,
)
if err != nil {
logging.Warn("Failed to write audit log: %v", err)
}
}
// GetAll returns paginated audit entries for all users (admin view).
// Returns entries, total count, and error.
func (s *Service) GetAll(page, perPage int) ([]AuditEntry, int, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
offset := (page - 1) * perPage
rows, err := s.db.Query(
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username,
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`,
perPage, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, nil
}
// GetAllExceptOwners returns paginated audit entries excluding entries from
// users with the "owner" role. This is the admin view.
func (s *Service) GetAllExceptOwners(page, perPage int) ([]AuditEntry, int, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var total int
if err := s.db.QueryRow(
`SELECT COUNT(*) FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE u.role IS NULL OR u.role != 'owner'`,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
offset := (page - 1) * perPage
rows, err := s.db.Query(
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username,
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE u.role IS NULL OR u.role != 'owner'
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`,
perPage, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, nil
}
// GetByUser returns paginated audit entries for a specific user.
func (s *Service) GetByUser(userID int64, page, perPage int) ([]AuditEntry, int, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE user_id = ?`, userID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
offset := (page - 1) * perPage
rows, err := s.db.Query(
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(deleted)') AS username,
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.user_id = ?
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?`,
userID, perPage, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
}
entries = append(entries, e)
}
return entries, total, nil
}
+658
View File
@@ -0,0 +1,658 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package auth
import (
"crypto/rand"
"database/sql"
"encoding/base32"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/models"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserExists = errors.New("username or email already exists")
ErrUserNotFound = errors.New("user not found")
ErrMFARequired = errors.New("mfa verification required")
ErrInvalidMFACode = errors.New("invalid MFA code")
ErrAccountLocked = errors.New("account is temporarily locked")
)
// Service handles user authentication
type Service struct {
db *database.DB
}
// NewService creates a new auth service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// Register creates a new user account. If mustChangePassword is true, the user
// will be forced to change their password on next login.
func (s *Service) Register(username, email, password, role string, mustChangePassword bool) (*models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
if role == "" {
role = "user"
}
mcp := 0
if mustChangePassword {
mcp = 1
}
result, err := s.db.Exec(
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, ?)`,
username, email, string(hash), role, mcp,
)
if err != nil {
return nil, ErrUserExists
}
id, _ := result.LastInsertId()
return &models.User{
ID: id,
Username: username,
Email: email,
Role: role,
MustChangePassword: mustChangePassword,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// Login authenticates a user and returns the user if successful
func (s *Service) Login(username, password string) (*models.User, error) {
user := &models.User{}
err := s.db.QueryRow(
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, must_change_password, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE username = ?`,
username,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, ErrInvalidCredentials
}
if err != nil {
return nil, fmt.Errorf("failed to query user: %w", err)
}
// Check account lockout
if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
return nil, ErrAccountLocked
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
// GetUserByID returns a user by their ID
func (s *Service) GetUserByID(id int64) (*models.User, error) {
user := &models.User{}
err := s.db.QueryRow(
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE id = ?`,
id,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to query user: %w", err)
}
return user, nil
}
// GetAllUsers returns all registered users (admin only)
func (s *Service) GetAllUsers() ([]models.User, error) {
rows, err := s.db.Query(
`SELECT id, username, email, role, mfa_enabled, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query users: %w", err)
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.MFAEnabled, &u.MustChangePassword, &u.FailedLoginAttempts, &u.LockedUntil, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, u)
}
return users, nil
}
// HasUsers checks if any users exist in the database
func (s *Service) HasUsers() (bool, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// EnsureAdmin creates a default owner user if no users exist.
// It auto-generates a secure password and returns (created, generatedPassword, error).
func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
hasUsers, err := s.HasUsers()
if err != nil {
return false, "", err
}
if hasUsers {
return false, "", nil
}
// Generate a secure random password (20 chars, base62)
password, err := generateSecurePassword(20)
if err != nil {
return false, "", fmt.Errorf("failed to generate password: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return false, "", fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`,
username, email, string(hash), "owner",
)
if err != nil {
return false, "", err
}
return true, password, nil
}
// generateSecurePassword creates a cryptographically secure random password
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = charset[b[i]%byte(len(charset))]
}
return string(b), nil
}
// UpdateUser updates user details (admin function)
func (s *Service) UpdateUser(id int64, username, email, role string) error {
_, err := s.db.Exec(
`UPDATE users SET username = ?, email = ?, role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
username, email, role, id,
)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
// UpdatePassword changes a user's password
func (s *Service) UpdatePassword(id int64, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
`UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
string(hash), id,
)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
return nil
}
// DeleteUser removes a user
func (s *Service) DeleteUser(id int64) error {
result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrUserNotFound
}
return nil
}
// CountByRole counts how many users have the given role
func (s *Service) CountByRole(role string) (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count)
if err != nil {
return 0, fmt.Errorf("failed to count users by role: %w", err)
}
return count, nil
}
// GenerateMFASecret generates a random TOTP secret
func (s *Service) GenerateMFASecret() string {
secret := make([]byte, 20)
rand.Read(secret)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
}
// EnableMFA stores the MFA secret for a user
func (s *Service) EnableMFA(userID int64, secret string) error {
_, err := s.db.Exec(
`UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
secret, userID,
)
return err
}
// DisableMFA removes MFA for a user
func (s *Service) DisableMFA(userID int64) error {
_, err := s.db.Exec(
`UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
userID,
)
return err
}
// UpdateTheme updates the user's theme preference (auto, light, dark)
func (s *Service) UpdateTheme(id int64, theme string) error {
if theme != "auto" && theme != "light" && theme != "dark" {
theme = "auto"
}
_, err := s.db.Exec(
`UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
theme, id,
)
if err != nil {
return fmt.Errorf("failed to update theme: %w", err)
}
return nil
}
// UpdateEmailNotifyLogin updates the user's login email notification setting
func (s *Service) UpdateEmailNotifyLogin(id int64, enabled bool) error {
val := 0
if enabled {
val = 1
}
_, err := s.db.Exec(
`UPDATE users SET email_notify_login = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
val, id,
)
if err != nil {
return fmt.Errorf("failed to update email notification setting: %w", err)
}
return nil
}
// UpdateAvatar updates the user's profile picture (base64-encoded data URI)
func (s *Service) UpdateAvatar(id int64, avatarBase64 string) error {
_, err := s.db.Exec(
`UPDATE users SET avatar_base64 = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
avatarBase64, id,
)
if err != nil {
return fmt.Errorf("failed to update avatar: %w", err)
}
return nil
}
// LegacyAvatar holds the minimal info needed for avatar migration
type LegacyAvatar struct {
ID int64
AvatarBase64 string
}
// GetUsersWithLegacyAvatars returns users whose avatar_base64 contains a data URI (legacy format)
func (s *Service) GetUsersWithLegacyAvatars() ([]LegacyAvatar, error) {
rows, err := s.db.Query(`SELECT id, avatar_base64 FROM users WHERE avatar_base64 LIKE 'data:%'`)
if err != nil {
return nil, fmt.Errorf("failed to query legacy avatars: %w", err)
}
defer rows.Close()
var results []LegacyAvatar
for rows.Next() {
var la LegacyAvatar
if err := rows.Scan(&la.ID, &la.AvatarBase64); err != nil {
return nil, fmt.Errorf("failed to scan legacy avatar: %w", err)
}
results = append(results, la)
}
return results, nil
}
// GetSetting reads a setting value
func (s *Service) GetSetting(key string) (string, error) {
var value string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
// SetSetting writes a setting value
func (s *Service) SetSetting(key, value string) error {
_, err := 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,
)
return err
}
// SetSettingsBatch writes multiple settings in a single transaction
func (s *Service) SetSettingsBatch(settings map[string]string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.Prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for k, v := range settings {
if _, err := stmt.Exec(k, v); err != nil {
return fmt.Errorf("failed to save setting %s: %w", k, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit settings: %w", err)
}
return nil
}
// GetAllSettings returns all settings as a map
func (s *Service) GetAllSettings() (map[string]string, error) {
rows, err := s.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
settings := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
settings[k] = v
}
return settings, nil
}
// --- Password Policy ---
// GetPasswordPolicy returns the current password policy from settings.
// Missing settings default to sensible values.
func (s *Service) GetPasswordPolicy() models.PasswordPolicy {
policy := models.PasswordPolicy{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireDigit: true,
RequireSpecial: false,
}
if v, _ := s.GetSetting("pw_min_length"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 4 {
policy.MinLength = n
}
}
if v, _ := s.GetSetting("pw_require_upper"); v == "false" {
policy.RequireUpper = false
}
if v, _ := s.GetSetting("pw_require_lower"); v == "false" {
policy.RequireLower = false
}
if v, _ := s.GetSetting("pw_require_digit"); v == "false" {
policy.RequireDigit = false
}
if v, _ := s.GetSetting("pw_require_special"); v == "true" {
policy.RequireSpecial = true
}
return policy
}
// ValidatePasswordPolicy checks a password against the configured policy.
// Returns nil if the password is compliant, otherwise a descriptive error.
func (s *Service) ValidatePasswordPolicy(password string) error {
policy := s.GetPasswordPolicy()
var violations []string
if len(password) < policy.MinLength {
violations = append(violations, fmt.Sprintf("at least %d characters", policy.MinLength))
}
if policy.RequireUpper {
hasUpper := false
for _, r := range password {
if unicode.IsUpper(r) {
hasUpper = true
break
}
}
if !hasUpper {
violations = append(violations, "at least one uppercase letter")
}
}
if policy.RequireLower {
hasLower := false
for _, r := range password {
if unicode.IsLower(r) {
hasLower = true
break
}
}
if !hasLower {
violations = append(violations, "at least one lowercase letter")
}
}
if policy.RequireDigit {
hasDigit := false
for _, r := range password {
if unicode.IsDigit(r) {
hasDigit = true
break
}
}
if !hasDigit {
violations = append(violations, "at least one digit")
}
}
if policy.RequireSpecial {
hasSpecial := false
for _, r := range password {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
hasSpecial = true
break
}
}
if !hasSpecial {
violations = append(violations, "at least one special character")
}
}
if len(violations) > 0 {
return fmt.Errorf("Password must contain: %s.", strings.Join(violations, ", "))
}
return nil
}
// --- Account Lockout ---
// RecordFailedLogin increments the failed login counter for a username
// and locks the account if the threshold is reached.
func (s *Service) RecordFailedLogin(username string) {
maxAttempts := 5
lockDuration := 15 // minutes
if v, _ := s.GetSetting("lockout_attempts"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
maxAttempts = n
}
}
if v, _ := s.GetSetting("lockout_duration"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lockDuration = n
}
}
// lockout_attempts == 0 means lockout is disabled
if maxAttempts == 0 {
return
}
// Increment counter
s.db.Exec(`UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE username = ?`, username)
// Check if threshold reached
var attempts int
err := s.db.QueryRow(`SELECT failed_login_attempts FROM users WHERE username = ?`, username).Scan(&attempts)
if err != nil {
return
}
if attempts >= maxAttempts {
lockUntil := time.Now().Add(time.Duration(lockDuration) * time.Minute)
s.db.Exec(`UPDATE users SET locked_until = ? WHERE username = ?`, lockUntil, username)
}
}
// ResetFailedLogins clears the failed login counter and lock for a user
func (s *Service) ResetFailedLogins(userID int64) {
s.db.Exec(`UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?`, userID)
}
// UnlockAccount clears the lock for a user (admin action)
func (s *Service) UnlockAccount(userID int64) error {
_, err := s.db.Exec(`UPDATE users SET failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
return err
}
// --- Force Password Change ---
// SetMustChangePassword sets or clears the must_change_password flag
func (s *Service) SetMustChangePassword(userID int64, must bool) error {
val := 0
if must {
val = 1
}
_, err := s.db.Exec(
`UPDATE users SET must_change_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
val, userID,
)
return err
}
// --- Last Login Tracking ---
// UpdateLastLogin records the current time as the user's last login
func (s *Service) UpdateLastLogin(userID int64) {
s.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
}
// --- Invitation Tokens ---
// CreateInvitationToken generates a secure random token for a user invitation.
// The token expires after the given duration.
func (s *Service) CreateInvitationToken(userID int64, expiry time.Duration) (string, error) {
// Generate a 32-byte random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("failed to generate token: %w", err)
}
token := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(tokenBytes)
expiresAt := time.Now().Add(expiry)
_, err := s.db.Exec(
`INSERT INTO invitation_tokens (user_id, token, expires_at) VALUES (?, ?, ?)`,
userID, token, expiresAt,
)
if err != nil {
return "", fmt.Errorf("failed to store invitation token: %w", err)
}
return token, nil
}
// GetInvitationByToken retrieves a valid (unused, not expired) invitation.
func (s *Service) GetInvitationByToken(token string) (*models.InvitationToken, error) {
inv := &models.InvitationToken{}
err := s.db.QueryRow(
`SELECT id, user_id, token, expires_at, used, created_at FROM invitation_tokens WHERE token = ?`,
token,
).Scan(&inv.ID, &inv.UserID, &inv.Token, &inv.ExpiresAt, &inv.Used, &inv.CreatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("invitation not found")
}
if err != nil {
return nil, fmt.Errorf("failed to query invitation: %w", err)
}
return inv, nil
}
// CompleteInvitation sets the user's password and marks the invitation as used.
func (s *Service) CompleteInvitation(token string, newPassword string) (*models.User, error) {
inv, err := s.GetInvitationByToken(token)
if err != nil {
return nil, err
}
if inv.Used {
return nil, fmt.Errorf("invitation has already been used")
}
if time.Now().After(inv.ExpiresAt) {
return nil, fmt.Errorf("invitation has expired")
}
// Hash the new password
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Update the user's password and clear must_change_password flag
_, err = s.db.Exec(
`UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
string(hash), inv.UserID,
)
if err != nil {
return nil, fmt.Errorf("failed to update user password: %w", err)
}
// Mark the invitation as used
_, err = s.db.Exec(`UPDATE invitation_tokens SET used = 1 WHERE id = ?`, inv.ID)
if err != nil {
return nil, fmt.Errorf("failed to mark invitation as used: %w", err)
}
// Return the user
return s.GetUserByID(inv.UserID)
}
+401
View File
@@ -0,0 +1,401 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
//go:build integration
package auth
import (
"path/filepath"
"testing"
"git.techniverse.net/scriptos/keywarden/internal/database"
)
func setupTestDB(t *testing.T) *database.DB {
t.Helper()
tmpDir := t.TempDir()
db, err := database.New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
return db
}
func TestRegisterAndLogin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
user, err := svc.Register("testuser", "test@example.com", "password123", "user", false)
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if user.Username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", user.Username)
}
if user.Role != "user" {
t.Fatalf("Expected role 'user', got %q", user.Role)
}
// Login with correct credentials
loggedIn, err := svc.Login("testuser", "password123")
if err != nil {
t.Fatalf("Login failed: %v", err)
}
if loggedIn.ID != user.ID {
t.Fatalf("Login returned different user ID")
}
}
func TestLoginWrongPassword(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
svc.Register("testuser", "test@example.com", "password123", "user", false)
_, err := svc.Login("testuser", "wrongpassword")
if err != ErrInvalidCredentials {
t.Fatalf("Expected ErrInvalidCredentials, got %v", err)
}
}
func TestLoginNonexistentUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
_, err := svc.Login("nonexistent", "password123")
if err != ErrInvalidCredentials {
t.Fatalf("Expected ErrInvalidCredentials, got %v", err)
}
}
func TestRegisterDuplicate(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
_, err := svc.Register("testuser", "test@example.com", "pass1", "user", false)
if err != nil {
t.Fatalf("First register failed: %v", err)
}
_, err = svc.Register("testuser", "test2@example.com", "pass2", "user", false)
if err != ErrUserExists {
t.Fatalf("Expected ErrUserExists, got %v", err)
}
}
func TestRegisterDefaultRole(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
user, err := svc.Register("testuser", "test@example.com", "pass", "", false)
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if user.Role != "user" {
t.Fatalf("Expected default role 'user', got %q", user.Role)
}
}
func TestGetUserByID(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "admin", false)
user, err := svc.GetUserByID(created.ID)
if err != nil {
t.Fatalf("GetUserByID failed: %v", err)
}
if user.Username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", user.Username)
}
if user.Role != "admin" {
t.Fatalf("Expected role 'admin', got %q", user.Role)
}
}
func TestGetUserByIDNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
_, err := svc.GetUserByID(999)
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound, got %v", err)
}
}
func TestUpdateUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
err := svc.UpdateUser(created.ID, "newname", "new@example.com", "admin")
if err != nil {
t.Fatalf("UpdateUser failed: %v", err)
}
user, _ := svc.GetUserByID(created.ID)
if user.Username != "newname" {
t.Fatalf("Expected updated username 'newname', got %q", user.Username)
}
if user.Email != "new@example.com" {
t.Fatalf("Expected updated email, got %q", user.Email)
}
if user.Role != "admin" {
t.Fatalf("Expected updated role 'admin', got %q", user.Role)
}
}
func TestUpdatePassword(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
err := svc.UpdatePassword(created.ID, "newpass")
if err != nil {
t.Fatalf("UpdatePassword failed: %v", err)
}
// Old password should fail
_, err = svc.Login("testuser", "oldpass")
if err != ErrInvalidCredentials {
t.Fatalf("Old password should no longer work")
}
// New password should work
_, err = svc.Login("testuser", "newpass")
if err != nil {
t.Fatalf("Login with new password failed: %v", err)
}
}
func TestDeleteUser(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
err := svc.DeleteUser(created.ID)
if err != nil {
t.Fatalf("DeleteUser failed: %v", err)
}
_, err = svc.GetUserByID(created.ID)
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound after deletion, got %v", err)
}
}
func TestDeleteUserNotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
err := svc.DeleteUser(999)
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound, got %v", err)
}
}
func TestEnsureAdmin(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, generatedPass, err := svc.EnsureAdmin("admin", "admin@test.com")
if err != nil {
t.Fatalf("EnsureAdmin failed: %v", err)
}
if !created {
t.Fatal("Expected user to be created")
}
if len(generatedPass) != 20 {
t.Fatalf("Expected 20-char generated password, got %d chars", len(generatedPass))
}
// Admin should be loginable with generated password
user, err := svc.Login("admin", generatedPass)
if err != nil {
t.Fatalf("Login as admin failed: %v", err)
}
if user.Role != "owner" {
t.Fatalf("Expected owner role, got %q", user.Role)
}
// Second call should be no-op
created2, _, err := svc.EnsureAdmin("admin2", "admin2@test.com")
if err != nil {
t.Fatalf("Second EnsureAdmin should not fail: %v", err)
}
if created2 {
t.Fatal("Second EnsureAdmin should not create a user")
}
// admin2 should NOT exist (was skipped)
_, err = svc.Login("admin2", "anypass")
if err != ErrInvalidCredentials {
t.Fatalf("admin2 should not have been created")
}
}
func TestGetAllUsers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
svc.Register("user1", "user1@test.com", "pass1", "user", false)
svc.Register("user2", "user2@test.com", "pass2", "admin", false)
users, err := svc.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers failed: %v", err)
}
if len(users) != 2 {
t.Fatalf("Expected 2 users, got %d", len(users))
}
}
func TestHasUsers(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
has, err := svc.HasUsers()
if err != nil {
t.Fatalf("HasUsers failed: %v", err)
}
if has {
t.Fatal("Expected no users initially")
}
svc.Register("user1", "user1@test.com", "pass1", "user", false)
has, err = svc.HasUsers()
if err != nil {
t.Fatalf("HasUsers failed: %v", err)
}
if !has {
t.Fatal("Expected users after registration")
}
}
func TestSettings(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
// Get non-existent setting
val, err := svc.GetSetting("app_name")
if err != nil {
t.Fatalf("GetSetting failed: %v", err)
}
if val != "" {
t.Fatalf("Expected empty value, got %q", val)
}
// Set and get
err = svc.SetSetting("app_name", "Keywarden Test")
if err != nil {
t.Fatalf("SetSetting failed: %v", err)
}
val, err = svc.GetSetting("app_name")
if err != nil {
t.Fatalf("GetSetting failed: %v", err)
}
if val != "Keywarden Test" {
t.Fatalf("Expected 'Keywarden Test', got %q", val)
}
// Update existing
err = svc.SetSetting("app_name", "Updated")
if err != nil {
t.Fatalf("SetSetting update failed: %v", err)
}
val, _ = svc.GetSetting("app_name")
if val != "Updated" {
t.Fatalf("Expected 'Updated', got %q", val)
}
}
func TestGetAllSettings(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
svc.SetSetting("key1", "val1")
svc.SetSetting("key2", "val2")
settings, err := svc.GetAllSettings()
if err != nil {
t.Fatalf("GetAllSettings failed: %v", err)
}
if len(settings) != 2 {
t.Fatalf("Expected 2 settings, got %d", len(settings))
}
if settings["key1"] != "val1" || settings["key2"] != "val2" {
t.Fatal("Settings values mismatch")
}
}
func TestMFASecret(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
secret := svc.GenerateMFASecret()
if len(secret) == 0 {
t.Fatal("MFA secret should not be empty")
}
// Should be valid base32
if len(secret) < 16 {
t.Fatalf("MFA secret seems too short: %d chars", len(secret))
}
}
func TestEnableDisableMFA(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
user, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
err := svc.EnableMFA(user.ID, "TESTSECRET")
if err != nil {
t.Fatalf("EnableMFA failed: %v", err)
}
updated, _ := svc.GetUserByID(user.ID)
if !updated.MFAEnabled {
t.Fatal("MFA should be enabled")
}
if updated.MFASecret != "TESTSECRET" {
t.Fatalf("MFA secret mismatch: got %q", updated.MFASecret)
}
err = svc.DisableMFA(user.ID)
if err != nil {
t.Fatalf("DisableMFA failed: %v", err)
}
updated, _ = svc.GetUserByID(user.ID)
if updated.MFAEnabled {
t.Fatal("MFA should be disabled")
}
}
+113
View File
@@ -0,0 +1,113 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"os"
"strconv"
"strings"
)
// Config holds all application configuration
type Config struct {
Port string
DBPath string
DataDir string
KeysDir string
MasterDir string
SessionKey string
EncryptionKey string
LogLevel string // ERROR, WARN, INFO (default), DEBUG, TRACE
// SMTP / Email
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPTLS bool
SMTPEnabled bool
// Security / Hardening
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
SecureCookies bool // set Secure flag on cookies (enable when behind HTTPS proxy)
RateLimitLogin int // max login POST attempts per IP per minute (0 = disabled)
MaxRequestSize int64 // max request body in bytes (0 = no limit)
}
// Load reads configuration from environment variables with sensible defaults
func Load() *Config {
smtpHost := getEnv("KEYWARDEN_SMTP_HOST", "")
// Parse BaseURL auto-derive SecureCookies from scheme if not explicitly set
baseURL := strings.TrimRight(getEnv("KEYWARDEN_BASE_URL", ""), "/")
secureCookiesExplicit := getEnv("KEYWARDEN_SECURE_COOKIES", "")
secureCookies := false
if secureCookiesExplicit != "" {
secureCookies = secureCookiesExplicit == "true"
} else if strings.HasPrefix(baseURL, "https://") {
secureCookies = true
}
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
return &Config{
Port: getEnv("KEYWARDEN_PORT", "8080"),
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
DataDir: getEnv("KEYWARDEN_DATA_DIR", "./data"),
KeysDir: getEnv("KEYWARDEN_KEYS_DIR", "./data/keys"),
MasterDir: getEnv("KEYWARDEN_MASTER_DIR", "./data/master"),
SessionKey: getEnv("KEYWARDEN_SESSION_KEY", "change-me-in-production-please"),
EncryptionKey: getEnv("KEYWARDEN_ENCRYPTION_KEY", "change-me-encryption-key-32chars"),
LogLevel: getEnv("KEYWARDEN_LOG_LEVEL", "INFO"),
SMTPHost: smtpHost,
SMTPPort: getEnv("KEYWARDEN_SMTP_PORT", "587"),
SMTPUser: getEnv("KEYWARDEN_SMTP_USER", ""),
SMTPPassword: getEnv("KEYWARDEN_SMTP_PASSWORD", ""),
SMTPFrom: getEnv("KEYWARDEN_SMTP_FROM", "keywarden@localhost"),
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
SMTPEnabled: smtpHost != "",
BaseURL: baseURL,
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
SecureCookies: secureCookies,
RateLimitLogin: rateLimitLogin,
MaxRequestSize: maxRequestSize,
}
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}
func getEnvInt(key string, fallback int) int {
s := getEnv(key, "")
if s == "" {
return fallback
}
v, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return v
}
func getEnvInt64(key string, fallback int64) int64 {
s := getEnv(key, "")
if s == "" {
return fallback
}
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return fallback
}
return v
}
+68
View File
@@ -0,0 +1,68 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"os"
"testing"
)
func TestLoadDefaults(t *testing.T) {
// Clear all KEYWARDEN_ env vars to ensure defaults
envs := []string{
"KEYWARDEN_PORT", "KEYWARDEN_DB_PATH", "KEYWARDEN_DATA_DIR",
"KEYWARDEN_KEYS_DIR", "KEYWARDEN_MASTER_DIR", "KEYWARDEN_SESSION_KEY",
"KEYWARDEN_ENCRYPTION_KEY",
}
for _, e := range envs {
os.Unsetenv(e)
}
cfg := Load()
if cfg.Port != "8080" {
t.Fatalf("Expected default port 8080, got %q", cfg.Port)
}
if cfg.DBPath != "./data/keywarden.db" {
t.Fatalf("Expected default DBPath, got %q", cfg.DBPath)
}
if cfg.DataDir != "./data" {
t.Fatalf("Expected default DataDir, got %q", cfg.DataDir)
}
if cfg.KeysDir != "./data/keys" {
t.Fatalf("Expected default KeysDir, got %q", cfg.KeysDir)
}
if cfg.MasterDir != "./data/master" {
t.Fatalf("Expected default MasterDir, got %q", cfg.MasterDir)
}
}
func TestLoadFromEnv(t *testing.T) {
os.Setenv("KEYWARDEN_PORT", "9090")
os.Setenv("KEYWARDEN_DB_PATH", "/custom/db.sqlite")
os.Setenv("KEYWARDEN_DATA_DIR", "/custom/data")
os.Setenv("KEYWARDEN_ENCRYPTION_KEY", "my-custom-key")
defer func() {
os.Unsetenv("KEYWARDEN_PORT")
os.Unsetenv("KEYWARDEN_DB_PATH")
os.Unsetenv("KEYWARDEN_DATA_DIR")
os.Unsetenv("KEYWARDEN_ENCRYPTION_KEY")
}()
cfg := Load()
if cfg.Port != "9090" {
t.Fatalf("Expected port 9090, got %q", cfg.Port)
}
if cfg.DBPath != "/custom/db.sqlite" {
t.Fatalf("Expected custom DBPath, got %q", cfg.DBPath)
}
if cfg.DataDir != "/custom/data" {
t.Fatalf("Expected custom DataDir, got %q", cfg.DataDir)
}
if cfg.EncryptionKey != "my-custom-key" {
t.Fatalf("Expected custom EncryptionKey, got %q", cfg.EncryptionKey)
}
}
+660
View File
@@ -0,0 +1,660 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package cron
import (
"crypto/rand"
"fmt"
"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"
)
// Service handles scheduled key deployments
type Service struct {
db *database.DB
deploy *deploy.Service
keys *keys.Service
servers *servers.Service
audit *audit.Service
stopCh chan struct{}
wg sync.WaitGroup
}
// NewService creates a new cron 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 cron scheduler loop (checks every 30 seconds)
func (s *Service) Start() {
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Run once immediately at startup
s.tick()
for {
select {
case <-ticker.C:
s.tick()
case <-s.stopCh:
return
}
}
}()
logging.Info("Cron scheduler started")
}
// Stop gracefully stops the cron scheduler
func (s *Service) Stop() {
close(s.stopCh)
s.wg.Wait()
}
// tick checks for jobs that need to run
func (s *Service) tick() {
now := time.Now().UTC()
jobs, err := s.GetPendingJobs(now)
if err != nil {
logging.Error("Cron: failed to get pending jobs: %v", err)
return
}
for _, job := range jobs {
s.executeJob(job)
}
}
// executeJob runs a single cron job
func (s *Service) executeJob(job models.CronJob) {
logging.Info("Cron: executing job '%s' (ID %d)", job.Name, job.ID)
// Mark as running
s.db.Exec(`UPDATE cron_jobs SET status = 'running', last_run = ? WHERE id = ?`, time.Now().UTC(), job.ID)
// Use TargetUserID (the key owner) instead of UserID (the admin who created the job)
key, err := s.keys.GetKeyByID(job.SSHKeyID, job.TargetUserID)
if err != nil {
s.failJob(job, fmt.Sprintf("key not found: %v", err))
return
}
// Use system master key for SSH authentication
masterKeyPEM, err := s.keys.GetSystemMasterKeyPrivate()
if err != nil {
s.failJob(job, fmt.Sprintf("system master key not available: %v", err))
return
}
// Read access config directly from the job
systemUser := job.SystemUser
createUser := job.CreateUser
sudo := job.Sudo
initialPassword := job.InitialPassword
// Auto-generate initial password if createUser is enabled and no password is set
if createUser && initialPassword == "" {
initialPassword = generateInitialPassword(10)
logging.Debug("Cron job '%s': auto-generated initial password for system user '%s'", job.Name, systemUser)
}
var targetServers []models.Server
if job.ServerID > 0 {
// Single server — use Global lookup since cron jobs are admin-created
srv, err := s.servers.GetByIDGlobal(job.ServerID)
if err != nil {
s.failJob(job, fmt.Sprintf("server not found: %v", err))
return
}
targetServers = append(targetServers, *srv)
} else if job.GroupID > 0 {
// Server group — use Global lookup since cron jobs are admin-created
members, err := s.servers.GetGroupMembersGlobal(job.GroupID)
if err != nil || len(members) == 0 {
s.failJob(job, fmt.Sprintf("group members not found: %v", err))
return
}
targetServers = members
} else {
s.failJob(job, "no target server or group specified")
return
}
var successCount, failCount int
for _, srv := range targetServers {
server := srv
var deployErr error
if systemUser != "" {
// Deploy to specific system user (from assignment)
deployErr = s.deploy.DeployKeyToUser(key, &server, masterKeyPEM, systemUser, createUser, sudo, initialPassword)
} else {
// Legacy: deploy to server's default user (root)
deployErr = s.deploy.DeployKey(key, &server, masterKeyPEM)
}
if deployErr != nil {
failCount++
logging.Error("Cron job '%s': deploy to %s@%s:%d failed: %v", job.Name, server.Username, server.Hostname, server.Port, deployErr)
} else {
successCount++
}
}
// Log result
targetInfo := ""
if systemUser != "" {
targetInfo = fmt.Sprintf(" (system user: %s)", systemUser)
}
details := fmt.Sprintf("Cron job '%s': deployed key '%s'%s — %d success, %d failed", job.Name, key.Name, targetInfo, successCount, failCount)
if failCount > 0 && successCount == 0 {
s.audit.Log(job.UserID, audit.ActionCronJobFailed, details, "cron")
} else {
s.audit.Log(job.UserID, audit.ActionCronJobExecuted, details, "cron")
}
// Store auto-generated initial password (encrypted) if it was generated and deploy succeeded
if initialPassword != "" && job.InitialPassword == "" && successCount > 0 {
if encPW, encErr := s.keys.EncryptValue(initialPassword); encErr == nil {
s.db.Exec(`UPDATE cron_jobs SET initial_password = ? WHERE id = ?`, encPW, job.ID)
} else {
logging.Warn("Cron job '%s': failed to encrypt initial password: %v", job.Name, encErr)
}
}
// Update job status
if job.Schedule == "once" {
// One-time job: mark as done
s.db.Exec(`UPDATE cron_jobs SET status = 'done', last_run = ?, message = ? WHERE id = ?`,
time.Now().UTC(), details, job.ID)
} else {
// Recurring job: calculate next run and stay active
nextRun := CalculateNextRun(job)
s.db.Exec(`UPDATE cron_jobs SET status = 'active', last_run = ?, next_run = ?, message = ? WHERE id = ?`,
time.Now().UTC(), nextRun, details, job.ID)
}
// Handle auto-removal after expiry (for temporary deployments)
if job.RemoveAfterMin > 0 {
s.wg.Add(1)
go func(j models.CronJob, serverList []models.Server, sshKey *models.SSHKey, masterPEM []byte) {
defer s.wg.Done()
timer := time.NewTimer(time.Duration(j.RemoveAfterMin) * time.Minute)
select {
case <-timer.C:
s.handleExpiry(j, serverList, sshKey, masterPEM)
case <-s.stopCh:
timer.Stop()
return
}
}(job, targetServers, key, masterKeyPEM)
}
}
// handleExpiry handles the expiry action for a temporary access job
func (s *Service) handleExpiry(job models.CronJob, serverList []models.Server, key *models.SSHKey, masterKeyPEM []byte) {
expiryAction := job.ExpiryAction
if expiryAction == "" {
expiryAction = "remove_key"
}
systemUser := job.SystemUser
logging.Info("Cron: expiry action '%s' for key '%s' after %d minutes (job '%s')", expiryAction, key.Name, job.RemoveAfterMin, job.Name)
for _, srv := range serverList {
server := srv
var err error
switch expiryAction {
case "disable_user":
if systemUser != "" {
err = s.deploy.DisableSystemUser(key, &server, masterKeyPEM, systemUser)
} else {
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
}
case "delete_user":
if systemUser != "" {
err = s.deploy.RemoveSystemUser(key, &server, masterKeyPEM, systemUser)
} else {
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
}
default: // "remove_key"
if systemUser != "" {
err = s.deploy.RemoveKeyFromUser(key, &server, masterKeyPEM, systemUser)
} else {
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
}
}
if err != nil {
logging.Error("Cron job '%s': expiry action '%s' on %s@%s:%d failed: %v", job.Name, expiryAction, server.Username, server.Hostname, server.Port, err)
}
}
actionLabel := map[string]string{"remove_key": "removed key", "disable_user": "disabled user", "delete_user": "deleted user"}[expiryAction]
details := fmt.Sprintf("Cron job '%s': %s '%s' on %d server(s) after %d min", job.Name, actionLabel, key.Name, len(serverList), job.RemoveAfterMin)
s.audit.Log(job.UserID, audit.ActionCronJobKeyRemoved, details, "cron")
}
// failJob marks a job as failed
func (s *Service) failJob(job models.CronJob, msg string) {
logging.Error("Cron job '%s' failed: %s", job.Name, msg)
status := "failed"
if job.Schedule != "once" {
// Recurring jobs stay active but log the failure
status = "active"
nextRun := CalculateNextRun(job)
s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, next_run = ?, message = ? WHERE id = ?`,
status, time.Now().UTC(), nextRun, msg, job.ID)
} else {
s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, message = ? WHERE id = ?`,
status, time.Now().UTC(), msg, job.ID)
}
s.audit.Log(job.UserID, audit.ActionCronJobFailed, fmt.Sprintf("Cron job '%s' failed: %s", job.Name, msg), "cron")
}
// CalculateNextRun computes the next execution time for recurring jobs.
// It uses the job's timezone and schedule parameters to ensure the execution
// time stays aligned (no drift). All returned times are in UTC.
func CalculateNextRun(job models.CronJob) time.Time {
loc, err := time.LoadLocation(job.Timezone)
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
switch job.Schedule {
case "hourly":
// Next occurrence of the specified minute
next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), job.MinuteOfHour, 0, 0, loc)
if !next.After(now) {
next = next.Add(1 * time.Hour)
}
return next.UTC()
case "daily":
// Next occurrence of the specified time of day
hour, minute := parseTimeOfDay(job.TimeOfDay)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next.UTC()
case "weekly":
// Next occurrence of the specified weekday at the specified time
hour, minute := parseTimeOfDay(job.TimeOfDay)
targetDay := time.Weekday(job.DayOfWeek)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
daysUntil := int(targetDay) - int(now.Weekday())
if daysUntil < 0 {
daysUntil += 7
}
next = next.AddDate(0, 0, daysUntil)
if !next.After(now) {
next = next.AddDate(0, 0, 7)
}
return next.UTC()
case "monthly":
// Next occurrence of the specified day of month at the specified time
hour, minute := parseTimeOfDay(job.TimeOfDay)
day := job.DayOfMonth
if day < 1 {
day = 1
}
next := safeDate(now.Year(), now.Month(), day, hour, minute, loc)
if !next.After(now) {
next = safeDate(now.Year(), now.Month()+1, day, hour, minute, loc)
}
return next.UTC()
default:
return now.Add(24 * time.Hour).UTC()
}
}
// CalculateFirstRun computes the initial next_run time for a new job.
// For "once", it uses the scheduled_at time directly.
// For recurring schedules, it finds the next matching time from now.
func CalculateFirstRun(job models.CronJob) time.Time {
if job.Schedule == "once" {
return job.ScheduledAt.UTC()
}
return CalculateNextRun(job)
}
// parseTimeOfDay parses "HH:MM" format and returns hour, minute
func parseTimeOfDay(tod string) (int, int) {
var hour, minute int
fmt.Sscanf(tod, "%d:%d", &hour, &minute)
if hour < 0 || hour > 23 {
hour = 0
}
if minute < 0 || minute > 59 {
minute = 0
}
return hour, minute
}
// safeDate creates a date, clamping the day to the last day of the month
func safeDate(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time {
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
if day > lastDay {
day = lastDay
}
return time.Date(year, month, day, hour, minute, 0, 0, loc)
}
// --- Database Operations ---
// Create creates a new temporary access job
func (s *Service) Create(userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) (*models.CronJob, error) {
if expiryAction == "" {
expiryAction = "remove_key"
}
job := models.CronJob{
UserID: userID,
Name: name,
SSHKeyID: keyID,
ServerID: serverID,
GroupID: groupID,
Schedule: schedule,
ScheduledAt: scheduledAt.UTC(),
RemoveAfterMin: removeAfterMin,
Timezone: tz,
TimeOfDay: timeOfDay,
DayOfWeek: dayOfWeek,
DayOfMonth: dayOfMonth,
MinuteOfHour: minuteOfHour,
TargetUserID: targetUserID,
SystemUser: systemUser,
Sudo: sudo,
CreateUser: createUser,
InitialPassword: initialPassword,
ExpiryAction: expiryAction,
Status: "active",
}
nextRun := CalculateFirstRun(job)
job.NextRun = nextRun
result, err := s.db.Exec(
`INSERT INTO cron_jobs (user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, remove_after_min, status, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
userID, name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin,
tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour, targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction,
)
if err != nil {
return nil, fmt.Errorf("failed to create cron job: %w", err)
}
id, _ := result.LastInsertId()
job.ID = id
return &job, nil
}
// GetByUser returns all cron jobs for a user
func (s *Service) GetByUser(userID int64) ([]models.CronJobDisplay, error) {
rows, err := s.db.Query(
`SELECT cj.id, cj.user_id, cj.name, cj.ssh_key_id, cj.server_id, cj.group_id,
cj.schedule, cj.scheduled_at, cj.next_run, cj.last_run, cj.remove_after_min,
cj.status, cj.message, cj.created_at,
cj.timezone, cj.time_of_day, cj.day_of_week, cj.day_of_month, cj.minute_of_hour,
cj.target_user_id, cj.system_user, cj.sudo, cj.create_user, cj.initial_password, cj.expiry_action,
COALESCE(sk.name, '(deleted)') as key_name,
COALESCE(srv.name, '') as server_name,
COALESCE(sg.name, '') as group_name,
COALESCE(tu.username, '') as target_username
FROM cron_jobs cj
LEFT JOIN ssh_keys sk ON cj.ssh_key_id = sk.id
LEFT JOIN servers srv ON cj.server_id = srv.id
LEFT JOIN server_groups sg ON cj.group_id = sg.id
LEFT JOIN users tu ON cj.target_user_id = tu.id
WHERE cj.user_id = ?
ORDER BY cj.created_at DESC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query cron jobs: %w", err)
}
defer rows.Close()
var jobs []models.CronJobDisplay
for rows.Next() {
var j models.CronJobDisplay
var lastRun, message *string
var serverName, groupName string
if err := rows.Scan(
&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID,
&j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin,
&j.Status, &message, &j.CreatedAt,
&j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour,
&j.TargetUserID, &j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction,
&j.KeyName, &serverName, &groupName,
&j.TargetUsername,
); err != nil {
continue
}
if lastRun != nil {
if t, ok := parseTimeString(*lastRun); ok {
j.LastRun = &t
}
}
if message != nil {
j.Message = *message
}
if serverName != "" {
j.TargetName = serverName
j.TargetType = "host"
} else if groupName != "" {
j.TargetName = groupName
j.TargetType = "group"
}
jobs = append(jobs, j)
}
return jobs, nil
}
// GetByID returns a specific cron job
func (s *Service) GetByID(jobID, userID int64) (*models.CronJob, error) {
job := &models.CronJob{}
var lastRun, message *string
err := s.db.QueryRow(
`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at,
timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id,
system_user, sudo, create_user, initial_password, expiry_action
FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID,
).Scan(&job.ID, &job.UserID, &job.Name, &job.SSHKeyID, &job.ServerID, &job.GroupID,
&job.Schedule, &job.ScheduledAt, &job.NextRun, &lastRun, &job.RemoveAfterMin,
&job.Status, &message, &job.CreatedAt,
&job.Timezone, &job.TimeOfDay, &job.DayOfWeek, &job.DayOfMonth, &job.MinuteOfHour,
&job.TargetUserID,
&job.SystemUser, &job.Sudo, &job.CreateUser, &job.InitialPassword, &job.ExpiryAction)
if err != nil {
return nil, fmt.Errorf("cron job not found: %w", err)
}
if lastRun != nil {
if t, ok := parseTimeString(*lastRun); ok {
job.LastRun = &t
}
}
if message != nil {
job.Message = *message
}
return job, nil
}
// Update updates a temporary access job
func (s *Service) Update(jobID, userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) error {
if expiryAction == "" {
expiryAction = "remove_key"
}
job := models.CronJob{
UserID: userID,
Name: name,
SSHKeyID: keyID,
ServerID: serverID,
GroupID: groupID,
Schedule: schedule,
ScheduledAt: scheduledAt.UTC(),
RemoveAfterMin: removeAfterMin,
Timezone: tz,
TimeOfDay: timeOfDay,
DayOfWeek: dayOfWeek,
DayOfMonth: dayOfMonth,
MinuteOfHour: minuteOfHour,
TargetUserID: targetUserID,
SystemUser: systemUser,
Sudo: sudo,
CreateUser: createUser,
InitialPassword: initialPassword,
ExpiryAction: expiryAction,
}
nextRun := CalculateFirstRun(job)
result, err := s.db.Exec(
`UPDATE cron_jobs SET name=?, ssh_key_id=?, server_id=?, group_id=?, schedule=?, scheduled_at=?, next_run=?, remove_after_min=?, status='active',
timezone=?, time_of_day=?, day_of_week=?, day_of_month=?, minute_of_hour=?,
target_user_id=?, system_user=?, sudo=?, create_user=?, initial_password=?, expiry_action=?
WHERE id=? AND user_id=?`,
name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin,
tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour,
targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction,
jobID, userID,
)
if err != nil {
return fmt.Errorf("failed to update cron job: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("cron job not found")
}
return nil
}
// Delete removes a cron job
func (s *Service) Delete(jobID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID)
if err != nil {
return fmt.Errorf("failed to delete cron job: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("cron job not found")
}
return nil
}
// TogglePause pauses or resumes a cron job
func (s *Service) TogglePause(jobID, userID int64) error {
job, err := s.GetByID(jobID, userID)
if err != nil {
return err
}
newStatus := "paused"
if job.Status == "paused" {
newStatus = "active"
// Recalculate next run when resuming
nextRun := CalculateNextRun(*job)
_, err = s.db.Exec(`UPDATE cron_jobs SET status = ?, next_run = ? WHERE id = ? AND user_id = ?`, newStatus, nextRun, jobID, userID)
return err
}
_, err = s.db.Exec(`UPDATE cron_jobs SET status = ? WHERE id = ? AND user_id = ?`, newStatus, jobID, userID)
return err
}
// GetPendingJobs returns jobs that are due for execution
func (s *Service) GetPendingJobs(now time.Time) ([]models.CronJob, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at,
timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id,
system_user, sudo, create_user, initial_password, expiry_action
FROM cron_jobs
WHERE status = 'active' AND next_run <= ?
ORDER BY next_run ASC`, now.UTC(),
)
if err != nil {
return nil, fmt.Errorf("failed to query pending jobs: %w", err)
}
defer rows.Close()
var jobs []models.CronJob
for rows.Next() {
var j models.CronJob
var lastRun, message *string
if err := rows.Scan(&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID,
&j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin,
&j.Status, &message, &j.CreatedAt,
&j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour,
&j.TargetUserID,
&j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction); err != nil {
continue
}
if lastRun != nil {
if t, ok := parseTimeString(*lastRun); ok {
j.LastRun = &t
}
}
if message != nil {
j.Message = *message
}
jobs = append(jobs, j)
}
return jobs, nil
}
// CountByUser returns total cron jobs for a user
func (s *Service) CountByUser(userID int64) int {
var count int
s.db.QueryRow(`SELECT COUNT(*) FROM cron_jobs WHERE user_id = ?`, userID).Scan(&count)
return count
}
// parseTimeString tries multiple time formats to parse a SQLite datetime string
func parseTimeString(s string) (time.Time, bool) {
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
// generateInitialPassword generates a random alphanumeric password
func generateInitialPassword(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
rand.Read(b)
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}
+293
View File
@@ -0,0 +1,293 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"io"
"time"
)
// BackupData contains all exportable data from the database
type BackupData struct {
Version string `json:"version"`
CreatedAt string `json:"created_at"`
Users []map[string]interface{} `json:"users"`
SSHKeys []map[string]interface{} `json:"ssh_keys"`
Servers []map[string]interface{} `json:"servers"`
ServerGroups []map[string]interface{} `json:"server_groups"`
GroupMembers []map[string]interface{} `json:"server_group_members"`
KeyDeployments []map[string]interface{} `json:"key_deployments"`
AuditLog []map[string]interface{} `json:"audit_log"`
Settings []map[string]interface{} `json:"settings"`
AccessAssign []map[string]interface{} `json:"access_assignments"`
CronJobs []map[string]interface{} `json:"cron_jobs"`
}
// ExportAll exports all database tables to a BackupData struct
func (d *DB) ExportAll() (*BackupData, error) {
backup := &BackupData{
Version: "1",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
tables := []struct {
query string
dest *[]map[string]interface{}
}{
{`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY id`, &backup.Users},
{`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, passphrase_enc, created_at FROM ssh_keys ORDER BY id`, &backup.SSHKeys},
{`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at FROM servers ORDER BY id`, &backup.Servers},
{`SELECT id, user_id, name, description, created_at, updated_at FROM server_groups ORDER BY id`, &backup.ServerGroups},
{`SELECT id, group_id, server_id FROM server_group_members ORDER BY id`, &backup.GroupMembers},
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
{`SELECT id, user_id, action, details, ip_address, created_at FROM audit_log ORDER BY id`, &backup.AuditLog},
{`SELECT key, value, updated_at FROM settings ORDER BY key`, &backup.Settings},
{`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at FROM access_assignments ORDER BY id`, &backup.AccessAssign},
{`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action, created_at FROM cron_jobs ORDER BY id`, &backup.CronJobs},
}
for _, t := range tables {
rows, err := d.Query(t.query)
if err != nil {
return nil, fmt.Errorf("failed to query table: %w", err)
}
data, err := rowsToMaps(rows)
rows.Close()
if err != nil {
return nil, fmt.Errorf("failed to read rows: %w", err)
}
*t.dest = data
}
return backup, nil
}
// ImportAll restores all database tables from a BackupData struct.
// It clears existing data and replaces it with the backup data.
func (d *DB) ImportAll(backup *BackupData) error {
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Disable foreign key checks during import
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("failed to disable foreign keys: %w", err)
}
// Clear all tables in dependency order
clearOrder := []string{
"cron_jobs",
"access_assignments",
"key_deployments",
"server_group_members",
"server_groups",
"ssh_keys",
"servers",
"audit_log",
"settings",
"users",
}
for _, table := range clearOrder {
if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
return fmt.Errorf("failed to clear table %s: %w", table, err)
}
}
// Import tables in reverse dependency order (parents first)
importOrder := []struct {
table string
columns []string
data []map[string]interface{}
}{
{"users", []string{"id", "username", "email", "password_hash", "role", "mfa_enabled", "mfa_secret", "theme", "email_notify_login", "avatar_base64", "must_change_password", "failed_login_attempts", "locked_until", "last_login_at", "created_at", "updated_at"}, backup.Users},
{"ssh_keys", []string{"id", "user_id", "name", "key_type", "bits", "fingerprint", "public_key", "private_key_enc", "passphrase_enc", "created_at"}, backup.SSHKeys},
{"servers", []string{"id", "user_id", "name", "hostname", "port", "username", "description", "created_at", "updated_at"}, backup.Servers},
{"server_groups", []string{"id", "user_id", "name", "description", "created_at", "updated_at"}, backup.ServerGroups},
{"server_group_members", []string{"id", "group_id", "server_id"}, backup.GroupMembers},
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message"}, backup.KeyDeployments},
{"audit_log", []string{"id", "user_id", "action", "details", "ip_address", "created_at"}, backup.AuditLog},
{"settings", []string{"key", "value", "updated_at"}, backup.Settings},
{"access_assignments", []string{"id", "user_id", "ssh_key_id", "server_id", "group_id", "system_user", "desired_state", "sudo", "create_user", "initial_password", "status", "last_sync_at", "created_at", "updated_at"}, backup.AccessAssign},
{"cron_jobs", []string{"id", "user_id", "name", "ssh_key_id", "server_id", "group_id", "schedule", "scheduled_at", "next_run", "last_run", "remove_after_min", "status", "message", "timezone", "time_of_day", "day_of_week", "day_of_month", "minute_of_hour", "target_user_id", "system_user", "sudo", "create_user", "initial_password", "expiry_action", "created_at"}, backup.CronJobs},
}
for _, imp := range importOrder {
if len(imp.data) == 0 {
continue
}
// Build INSERT statement
placeholders := ""
for i := range imp.columns {
if i > 0 {
placeholders += ", "
}
placeholders += "?"
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", imp.table, joinColumns(imp.columns), placeholders)
stmt, err := tx.Prepare(query)
if err != nil {
return fmt.Errorf("failed to prepare insert for %s: %w", imp.table, err)
}
for _, row := range imp.data {
args := make([]interface{}, len(imp.columns))
for i, col := range imp.columns {
args[i] = row[col]
}
if _, err := stmt.Exec(args...); err != nil {
stmt.Close()
return fmt.Errorf("failed to insert into %s: %w", imp.table, err)
}
}
stmt.Close()
}
// Re-enable foreign key checks
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("failed to re-enable foreign keys: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// EncryptBackup encrypts JSON backup data with AES-256-GCM using the given password
func EncryptBackup(data []byte, password string) ([]byte, error) {
key := sha256.Sum256([]byte(password))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Prepend a magic header so we can identify backup files
magic := []byte("KWBAK1") // Keywarden Backup v1
ciphertext := gcm.Seal(nonce, nonce, data, nil)
result := append(magic, ciphertext...)
return result, nil
}
// DecryptBackup decrypts an AES-256-GCM encrypted backup with the given password
func DecryptBackup(encrypted []byte, password string) ([]byte, error) {
// Check magic header
magic := []byte("KWBAK1")
if len(encrypted) < len(magic) {
return nil, fmt.Errorf("invalid backup file: too short")
}
if string(encrypted[:len(magic)]) != string(magic) {
return nil, fmt.Errorf("invalid backup file: wrong format")
}
encrypted = encrypted[len(magic):]
key := sha256.Sum256([]byte(password))
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(encrypted) < nonceSize {
return nil, fmt.Errorf("invalid backup file: ciphertext too short")
}
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed: wrong password or corrupted file")
}
return plaintext, nil
}
// rowsToMaps converts sql.Rows to a slice of maps
func rowsToMaps(rows *sql.Rows) ([]map[string]interface{}, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
var result []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
row := make(map[string]interface{})
for i, col := range columns {
val := values[i]
// Convert byte slices to strings for JSON serialization
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else {
row[col] = val
}
}
result = append(result, row)
}
if result == nil {
result = []map[string]interface{}{}
}
return result, rows.Err()
}
// joinColumns joins column names with commas
func joinColumns(cols []string) string {
result := ""
for i, col := range cols {
if i > 0 {
result += ", "
}
result += col
}
return result
}
// ParseBackupJSON parses decrypted JSON data into a BackupData struct
func ParseBackupJSON(data []byte) (*BackupData, error) {
var backup BackupData
if err := json.Unmarshal(data, &backup); err != nil {
return nil, fmt.Errorf("failed to parse backup data: %w", err)
}
if backup.Version == "" {
return nil, fmt.Errorf("invalid backup: missing version")
}
return &backup, nil
}
+101
View File
@@ -0,0 +1,101 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"encoding/json"
"testing"
)
func TestEncryptDecryptBackup(t *testing.T) {
password := "TestP@ssw0rd123!"
original := &BackupData{
Version: "1",
CreatedAt: "2026-04-05T12:00:00Z",
Users: []map[string]interface{}{
{"id": float64(1), "username": "admin", "email": "admin@test.local"},
},
SSHKeys: []map[string]interface{}{},
Servers: []map[string]interface{}{},
ServerGroups: []map[string]interface{}{},
GroupMembers: []map[string]interface{}{},
}
jsonData, err := json.Marshal(original)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
encrypted, err := EncryptBackup(jsonData, password)
if err != nil {
t.Fatalf("Failed to encrypt: %v", err)
}
// Check magic header
if string(encrypted[:6]) != "KWBAK1" {
t.Error("Missing KWBAK1 magic header")
}
// Decrypt with correct password
decrypted, err := DecryptBackup(encrypted, password)
if err != nil {
t.Fatalf("Failed to decrypt: %v", err)
}
// Verify content matches
var restored BackupData
if err := json.Unmarshal(decrypted, &restored); err != nil {
t.Fatalf("Failed to unmarshal decrypted data: %v", err)
}
if restored.Version != original.Version {
t.Errorf("Version mismatch: got %s, want %s", restored.Version, original.Version)
}
if len(restored.Users) != 1 {
t.Errorf("Expected 1 user, got %d", len(restored.Users))
}
// Wrong password should fail
_, err = DecryptBackup(encrypted, "wrong-password")
if err == nil {
t.Error("Expected error with wrong password, got nil")
}
}
func TestDecryptBackupInvalidFile(t *testing.T) {
_, err := DecryptBackup([]byte("invalid data"), "password")
if err == nil {
t.Error("Expected error for invalid file, got nil")
}
_, err = DecryptBackup([]byte("short"), "password")
if err == nil {
t.Error("Expected error for too-short data, got nil")
}
}
func TestParseBackupJSON(t *testing.T) {
valid := `{"version":"1","created_at":"2026-04-05T12:00:00Z","users":[],"ssh_keys":[],"servers":[],"server_groups":[],"server_group_members":[],"key_deployments":[],"audit_log":[],"settings":[],"access_assignments":[],"cron_jobs":[]}`
backup, err := ParseBackupJSON([]byte(valid))
if err != nil {
t.Fatalf("Failed to parse valid JSON: %v", err)
}
if backup.Version != "1" {
t.Errorf("Version mismatch: got %s, want 1", backup.Version)
}
// Missing version
invalid := `{"created_at":"2026-04-05T12:00:00Z"}`
_, err = ParseBackupJSON([]byte(invalid))
if err == nil {
t.Error("Expected error for missing version, got nil")
}
// Invalid JSON
_, err = ParseBackupJSON([]byte("not json"))
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
+250
View File
@@ -0,0 +1,250 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
// DB wraps the sql.DB connection
type DB struct {
*sql.DB
}
// New creates a new database connection and runs migrations
func New(dbPath string) (*DB, error) {
// Ensure directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
d := &DB{db}
if err := d.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return d, nil
}
// migrate creates all required tables
func (d *DB) migrate() error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
key_type TEXT NOT NULL,
bits INTEGER,
fingerprint TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key_enc TEXT NOT NULL,
passphrase_enc TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS key_deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ssh_key_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Migration: add MFA columns to users if not present
`CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT)`,
`CREATE TABLE IF NOT EXISTS server_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS server_group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
FOREIGN KEY (group_id) REFERENCES server_groups(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE(group_id, server_id)
)`,
`CREATE TABLE IF NOT EXISTS access_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ssh_key_id INTEGER NOT NULL,
server_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
system_user TEXT NOT NULL,
desired_state TEXT NOT NULL DEFAULT 'present',
sudo INTEGER NOT NULL DEFAULT 0,
create_user INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
last_sync_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
ssh_key_id INTEGER NOT NULL,
server_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
schedule TEXT NOT NULL DEFAULT 'once',
scheduled_at DATETIME NOT NULL,
next_run DATETIME NOT NULL,
last_run DATETIME,
remove_after_min INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE
)`,
}
for _, m := range migrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
}
// Conditional migrations (ALTER TABLE)
alterMigrations := map[string]string{
"add_mfa_enabled": `ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0`,
"add_mfa_secret": `ALTER TABLE users ADD COLUMN mfa_secret TEXT DEFAULT ''`,
"add_is_master_key": `ALTER TABLE ssh_keys ADD COLUMN is_master INTEGER NOT NULL DEFAULT 0`,
"add_user_theme": `ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto'`,
"add_email_notify_login": `ALTER TABLE users ADD COLUMN email_notify_login INTEGER NOT NULL DEFAULT 0`,
"add_avatar_base64": `ALTER TABLE users ADD COLUMN avatar_base64 TEXT NOT NULL DEFAULT ''`,
"add_cron_auth_key_id": `ALTER TABLE cron_jobs ADD COLUMN auth_key_id INTEGER NOT NULL DEFAULT 0`,
"add_initial_password": `ALTER TABLE access_assignments ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`,
"add_cron_timezone": `ALTER TABLE cron_jobs ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'`,
"add_cron_time_of_day": `ALTER TABLE cron_jobs ADD COLUMN time_of_day TEXT NOT NULL DEFAULT '00:00'`,
"add_cron_day_of_week": `ALTER TABLE cron_jobs ADD COLUMN day_of_week INTEGER NOT NULL DEFAULT -1`,
"add_cron_day_of_month": `ALTER TABLE cron_jobs ADD COLUMN day_of_month INTEGER NOT NULL DEFAULT 0`,
"add_cron_minute_of_hour": `ALTER TABLE cron_jobs ADD COLUMN minute_of_hour INTEGER NOT NULL DEFAULT 0`,
"add_cron_target_user_id": `ALTER TABLE cron_jobs ADD COLUMN target_user_id INTEGER NOT NULL DEFAULT 0`,
"add_cron_assignment_id": `ALTER TABLE cron_jobs ADD COLUMN assignment_id INTEGER NOT NULL DEFAULT 0`,
"add_cron_system_user": `ALTER TABLE cron_jobs ADD COLUMN system_user TEXT NOT NULL DEFAULT ''`,
"add_cron_sudo": `ALTER TABLE cron_jobs ADD COLUMN sudo INTEGER NOT NULL DEFAULT 0`,
"add_cron_create_user": `ALTER TABLE cron_jobs ADD COLUMN create_user INTEGER NOT NULL DEFAULT 0`,
"add_cron_init_password": `ALTER TABLE cron_jobs ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`,
"add_cron_expiry_action": `ALTER TABLE cron_jobs ADD COLUMN expiry_action TEXT NOT NULL DEFAULT 'remove_key'`,
"add_must_change_password": `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
"add_failed_login_attempts": `ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0`,
"add_locked_until": `ALTER TABLE users ADD COLUMN locked_until DATETIME`,
"add_last_login_at": `ALTER TABLE users ADD COLUMN last_login_at DATETIME`,
}
// Invitation tokens table (created via migration to avoid altering initial schema)
inviteTableMigration := map[string]string{
"create_invitation_tokens": `CREATE TABLE IF NOT EXISTS invitation_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
}
for name, sql := range inviteTableMigration {
var count int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
if count == 0 {
if _, err := d.Exec(sql); err != nil {
return fmt.Errorf("migration %s failed: %w", name, err)
}
d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name)
}
}
for name, sql := range alterMigrations {
var count int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
if count == 0 {
d.Exec(sql) // ignore error if column already exists
d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name)
}
}
// Role model migration: promote first admin to owner if no owner exists yet
{
var migCount int
d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'promote_admin_to_owner'`).Scan(&migCount)
if migCount == 0 {
var ownerCount int
d.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'owner'`).Scan(&ownerCount)
if ownerCount == 0 {
// Find the first admin (by ID) and promote to owner
var firstAdminID int64
err := d.QueryRow(`SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1`).Scan(&firstAdminID)
if err == nil && firstAdminID > 0 {
d.Exec(`UPDATE users SET role = 'owner' WHERE id = ?`, firstAdminID)
}
}
d.Exec(`INSERT INTO _migrations (name) VALUES ('promote_admin_to_owner')`)
}
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
//go:build integration
package database
import (
"os"
"path/filepath"
"testing"
)
func TestNewDatabase(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := New(dbPath)
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Verify file was created
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("Database file was not created")
}
}
func TestMigrationsCreateTables(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
tables := []string{"users", "ssh_keys", "servers", "key_deployments", "audit_log", "settings", "_migrations"}
for _, table := range tables {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
if err != nil {
t.Fatalf("Failed to check table %s: %v", table, err)
}
if count == 0 {
t.Fatalf("Table %q was not created", table)
}
}
}
func TestMigrationsIdempotent(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Run migrations twice (opening creates + migrates)
db1, err := New(dbPath)
if err != nil {
t.Fatalf("New() first call failed: %v", err)
}
db1.Close()
db2, err := New(dbPath)
if err != nil {
t.Fatalf("New() second call failed (migrations should be idempotent): %v", err)
}
defer db2.Close()
}
func TestAlterMigrationsTracked(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Check that alter migrations are tracked
expectedMigrations := []string{"add_mfa_enabled", "add_mfa_secret", "add_is_master_key"}
for _, name := range expectedMigrations {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
if err != nil {
t.Fatalf("Failed to check migration %s: %v", name, err)
}
if count == 0 {
t.Fatalf("Migration %q was not tracked", name)
}
}
}
func TestInsertAndQueryUser(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
result, err := db.Exec(
`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
"testuser", "test@example.com", "hash123", "user",
)
if err != nil {
t.Fatalf("Insert user failed: %v", err)
}
id, _ := result.LastInsertId()
if id == 0 {
t.Fatal("Expected non-zero insert ID")
}
var username string
err = db.QueryRow(`SELECT username FROM users WHERE id = ?`, id).Scan(&username)
if err != nil {
t.Fatalf("Query user failed: %v", err)
}
if username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", username)
}
}
func TestSSHKeyWithMasterFlag(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Create a user first
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
"testuser", "test@example.com", "hash", "user")
// Insert a master key
_, err = db.Exec(
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, is_master)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1, "Master Key", "ed25519", 256, "SHA256:test", "pubkey", "encpriv", 1,
)
if err != nil {
t.Fatalf("Insert master key failed: %v", err)
}
// Verify is_master flag
var isMaster int
err = db.QueryRow(`SELECT is_master FROM ssh_keys WHERE user_id = 1 AND is_master = 1`).Scan(&isMaster)
if err != nil {
t.Fatalf("Query master key failed: %v", err)
}
if isMaster != 1 {
t.Fatalf("Expected is_master=1, got %d", isMaster)
}
}
func TestForeignKeyCascade(t *testing.T) {
tmpDir := t.TempDir()
db, err := New(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("New() failed: %v", err)
}
defer db.Close()
// Insert user and key
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
"testuser", "test@example.com", "hash", "user")
db.Exec(`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
VALUES (?, ?, ?, ?, ?, ?, ?)`, 1, "Test", "ed25519", 256, "fp", "pub", "priv")
// Delete user — key should cascade
db.Exec(`DELETE FROM users WHERE id = 1`)
var count int
db.QueryRow(`SELECT COUNT(*) FROM ssh_keys WHERE user_id = 1`).Scan(&count)
if count != 0 {
t.Fatalf("Expected 0 keys after user deletion (cascade), got %d", count)
}
}
+657
View File
@@ -0,0 +1,657 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package deploy
import (
"fmt"
"net"
"strings"
"time"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/logging"
"git.techniverse.net/scriptos/keywarden/internal/models"
"golang.org/x/crypto/ssh"
)
// Service handles deploying SSH keys to remote servers
type Service struct {
db *database.DB
}
// NewService creates a new deploy service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// DeployKey deploys a public key to a remote server's authorized_keys
func (s *Service) DeployKey(key *models.SSHKey, server *models.Server, authPrivateKey []byte) error {
logging.Debug("Deploy: connecting to %s@%s:%d with key auth for key '%s'", server.Username, server.Hostname, server.Port, key.Name)
// Parse the private key used for authentication
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(), // TODO: implement known_hosts
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("session failed: %v", err))
return fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
// Clean the public key (remove trailing newline)
pubKey := strings.TrimSpace(key.PublicKey)
// Append to authorized_keys
cmd := fmt.Sprintf(
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u -o ~/.ssh/authorized_keys ~/.ssh/authorized_keys`,
pubKey,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key: %w", err)
}
s.logDeployment(key.ID, server.ID, "success", "key deployed successfully")
return nil
}
// DeployKeyWithPassword deploys a public key using password authentication
func (s *Service) DeployKeyWithPassword(key *models.SSHKey, server *models.Server, password string) error {
logging.Debug("Deploy: connecting to %s@%s:%d with password auth for key '%s'", server.Username, server.Hostname, server.Port, key.Name)
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("session failed: %v", err))
return fmt.Errorf("failed to create session: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
cmd := fmt.Sprintf(
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u -o ~/.ssh/authorized_keys ~/.ssh/authorized_keys`,
pubKey,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key: %w", err)
}
s.logDeployment(key.ID, server.ID, "success", "key deployed successfully")
return nil
}
// RemoveKey removes a public key from a remote server's authorized_keys
func (s *Service) RemoveKey(key *models.SSHKey, server *models.Server, authPrivateKey []byte) error {
logging.Debug("Deploy: removing key '%s' from %s@%s:%d", key.Name, server.Username, server.Hostname, server.Port)
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 := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for key removal: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key removal: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
// Escape single quotes in the key for safe sed usage
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
cmd := fmt.Sprintf(
`grep -v '%s' ~/.ssh/authorized_keys > ~/.ssh/authorized_keys.tmp 2>/dev/null && mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys || true`,
escapedKey,
)
if err := session.Run(cmd); err != nil {
return fmt.Errorf("failed to remove key: %w", err)
}
s.logDeployment(key.ID, server.ID, "success", "key removed successfully")
return nil
}
// DeployKeyToUser deploys a public key to a specific system user's authorized_keys.
// It connects to the server as the server's admin user and manages the target systemUser.
// If createUser is true, the system user will be created if it doesn't exist.
// If sudo is true, a sudoers.d entry with NOPASSWD will be created.
// If initialPassword is set and createUser is true, the password will be set on the system user.
func (s *Service) DeployKeyToUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string, createUser, sudo bool, initialPassword string) error {
logging.Debug("Deploy: connecting to %s@%s:%d to deploy key '%s' for system user '%s' (createUser=%v, sudo=%v)",
server.Username, server.Hostname, server.Port, key.Name, systemUser, createUser, sudo)
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 := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
// If createUser is true, ensure the system user exists
if createUser {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user creation: %w", err)
}
// Create user if not exists; use -m for home directory, -s for shell
createCmd := fmt.Sprintf(
`id '%s' >/dev/null 2>&1 || useradd -m -s /bin/bash '%s'`,
systemUser, systemUser,
)
if cerr := session.Run(createCmd); cerr != nil {
session.Close()
return fmt.Errorf("failed to create system user '%s': %w", systemUser, cerr)
}
session.Close()
logging.Info("Deploy: ensured system user '%s' exists on %s", systemUser, server.Hostname)
// Set initial password if provided
if initialPassword != "" {
pwSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for password setup: %w", err)
}
pwCmd := fmt.Sprintf(`echo '%s:%s' | chpasswd`, systemUser, initialPassword)
if perr := pwSession.Run(pwCmd); perr != nil {
pwSession.Close()
logging.Warn("Deploy: failed to set initial password for user '%s' on %s: %v", systemUser, server.Hostname, perr)
} else {
pwSession.Close()
logging.Info("Deploy: set initial password for user '%s' on %s", systemUser, server.Hostname)
}
}
}
// If sudo is true, create a sudoers.d entry with NOPASSWD
if sudo {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for sudo setup: %w", err)
}
sudoCmd := fmt.Sprintf(
`echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s && chmod 440 /etc/sudoers.d/%s`,
systemUser, systemUser, systemUser,
)
if serr := session.Run(sudoCmd); serr != nil {
session.Close()
logging.Warn("Deploy: failed to add sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
} else {
session.Close()
logging.Info("Deploy: ensured NOPASSWD sudo for user '%s' on %s", systemUser, server.Hostname)
}
}
// Deploy the key to the system user's authorized_keys
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key deployment: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
cmd := fmt.Sprintf(
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && echo '%s' >> %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown -R '%s':'%s' %s/.ssh && sort -u -o %s/.ssh/authorized_keys %s/.ssh/authorized_keys`,
homeDir, homeDir, pubKey, homeDir, homeDir, systemUser, systemUser, homeDir, homeDir, homeDir,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key for user '%s': %w", systemUser, err)
}
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key deployed to user '%s'", systemUser))
return nil
}
// DeployKeyToUserWithPassword is like DeployKeyToUser but uses password authentication
func (s *Service) DeployKeyToUserWithPassword(key *models.SSHKey, server *models.Server, password string, systemUser string, createUser, sudo bool, initialPassword string) error {
logging.Debug("Deploy: connecting to %s@%s:%d with password to deploy key '%s' for system user '%s'",
server.Username, server.Hostname, server.Port, key.Name, systemUser)
config := &ssh.ClientConfig{
User: server.Username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
return fmt.Errorf("failed to connect to server: %w", err)
}
defer client.Close()
if createUser {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user creation: %w", err)
}
createCmd := fmt.Sprintf(
`id '%s' >/dev/null 2>&1 || useradd -m -s /bin/bash '%s'`,
systemUser, systemUser,
)
if cerr := session.Run(createCmd); cerr != nil {
session.Close()
return fmt.Errorf("failed to create system user '%s': %w", systemUser, cerr)
}
session.Close()
logging.Info("Deploy: ensured system user '%s' exists on %s", systemUser, server.Hostname)
// Set initial password if provided
if initialPassword != "" {
pwSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for password setup: %w", err)
}
pwCmd := fmt.Sprintf(`echo '%s:%s' | chpasswd`, systemUser, initialPassword)
if perr := pwSession.Run(pwCmd); perr != nil {
pwSession.Close()
logging.Warn("Deploy: failed to set initial password for user '%s' on %s: %v", systemUser, server.Hostname, perr)
} else {
pwSession.Close()
logging.Info("Deploy: set initial password for user '%s' on %s", systemUser, server.Hostname)
}
}
}
if sudo {
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for sudo setup: %w", err)
}
sudoCmd := fmt.Sprintf(
`echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s && chmod 440 /etc/sudoers.d/%s`,
systemUser, systemUser, systemUser,
)
if serr := session.Run(sudoCmd); serr != nil {
session.Close()
logging.Warn("Deploy: failed to add sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
} else {
session.Close()
logging.Info("Deploy: ensured NOPASSWD sudo for user '%s' on %s", systemUser, server.Hostname)
}
}
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key deployment: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
cmd := fmt.Sprintf(
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && echo '%s' >> %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown -R '%s':'%s' %s/.ssh && sort -u -o %s/.ssh/authorized_keys %s/.ssh/authorized_keys`,
homeDir, homeDir, pubKey, homeDir, homeDir, systemUser, systemUser, homeDir, homeDir, homeDir,
)
if err := session.Run(cmd); err != nil {
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
return fmt.Errorf("failed to deploy key for user '%s': %w", systemUser, err)
}
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key deployed to user '%s'", systemUser))
return nil
}
// RemoveKeyFromUser removes a public key from a specific system user's authorized_keys.
// It connects as the server's admin user and manages the target systemUser's keys.
func (s *Service) RemoveKeyFromUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
logging.Debug("Deploy: connecting to %s@%s:%d to remove key '%s' from system user '%s'",
server.Username, server.Hostname, server.Port, key.Name, systemUser)
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 := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for key removal: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key removal: %w", err)
}
defer session.Close()
pubKey := strings.TrimSpace(key.PublicKey)
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
cmd := fmt.Sprintf(
`grep -v '%s' %s/.ssh/authorized_keys > %s/.ssh/authorized_keys.tmp 2>/dev/null && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown '%s':'%s' %s/.ssh/authorized_keys || true`,
escapedKey, homeDir, homeDir, homeDir, homeDir, homeDir, systemUser, systemUser, homeDir,
)
if err := session.Run(cmd); err != nil {
return fmt.Errorf("failed to remove key from user '%s': %w", systemUser, err)
}
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key removed from user '%s'", systemUser))
return nil
}
// RemoveSystemUser removes a Linux system user from a server, including:
// - Removing the SSH key from their authorized_keys
// - Removing sudo rights (/etc/sudoers.d/<user>)
// - Deleting the system user account (userdel -r)
func (s *Service) RemoveSystemUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
logging.Info("Deploy: removing system user '%s' from %s@%s:%d", systemUser, server.Username, server.Hostname, server.Port)
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 := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for user removal: %w", err)
}
defer client.Close()
// Step 1: Remove sudoers entry
sudoSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for sudo removal: %w", err)
}
sudoCmd := fmt.Sprintf(`rm -f /etc/sudoers.d/'%s'`, systemUser)
if serr := sudoSession.Run(sudoCmd); serr != nil {
logging.Warn("Deploy: failed to remove sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
} else {
logging.Info("Deploy: removed sudoers entry for '%s' on %s", systemUser, server.Hostname)
}
sudoSession.Close()
// Step 2: Kill all processes of the user (so userdel doesn't fail)
killSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for process kill: %w", err)
}
killCmd := fmt.Sprintf(`pkill -u '%s' 2>/dev/null || true`, systemUser)
killSession.Run(killCmd)
killSession.Close()
// Small delay to let processes terminate
time.Sleep(1 * time.Second)
// Step 3: Delete the system user with home directory
delSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user deletion: %w", err)
}
defer delSession.Close()
delCmd := fmt.Sprintf(`userdel -r '%s' 2>/dev/null || userdel '%s' 2>/dev/null`, systemUser, systemUser)
if derr := delSession.Run(delCmd); derr != nil {
return fmt.Errorf("failed to delete system user '%s': %w", systemUser, derr)
}
logging.Info("Deploy: successfully deleted system user '%s' from %s", systemUser, server.Hostname)
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("system user '%s' deleted", systemUser))
return nil
}
// DisableSystemUser locks a system user account on a server by:
// - Removing the SSH key from authorized_keys
// - Locking the account (usermod --lock)
// - Setting the shell to /usr/sbin/nologin
func (s *Service) DisableSystemUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
logging.Info("Deploy: disabling system user '%s' on %s@%s:%d", systemUser, server.Username, server.Hostname, server.Port)
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 := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("failed to connect to server for user disable: %w", err)
}
defer client.Close()
// Step 1: Remove SSH key from authorized_keys
removeSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for key removal: %w", err)
}
pubKey := strings.TrimSpace(key.PublicKey)
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
homeDir := fmt.Sprintf("/home/%s", systemUser)
if systemUser == "root" {
homeDir = "/root"
}
removeCmd := fmt.Sprintf(
`grep -v '%s' %s/.ssh/authorized_keys > %s/.ssh/authorized_keys.tmp 2>/dev/null && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys || true`,
escapedKey, homeDir, homeDir, homeDir, homeDir, homeDir,
)
removeSession.Run(removeCmd)
removeSession.Close()
// Step 2: Lock the account
lockSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for user lock: %w", err)
}
lockCmd := fmt.Sprintf(`usermod --lock '%s' 2>/dev/null || true`, systemUser)
lockSession.Run(lockCmd)
lockSession.Close()
// Step 3: Set shell to nologin
shellSession, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create session for shell change: %w", err)
}
defer shellSession.Close()
shellCmd := fmt.Sprintf(`usermod --shell /usr/sbin/nologin '%s' 2>/dev/null || chsh -s /usr/sbin/nologin '%s' 2>/dev/null || true`, systemUser, systemUser)
shellSession.Run(shellCmd)
logging.Info("Deploy: successfully disabled system user '%s' on %s", systemUser, server.Hostname)
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("system user '%s' disabled (locked + nologin)", systemUser))
return nil
}
// TestConnection tests TCP connectivity to a server (port reachable)
func (s *Service) TestConnection(hostname string, port int) error {
logging.Debug("Testing TCP connection to %s:%d", hostname, port)
addr := fmt.Sprintf("%s:%d", hostname, port)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", addr, err)
}
conn.Close()
return nil
}
// TestSSHAuth tests actual SSH authentication to a server using a private key
func (s *Service) TestSSHAuth(hostname string, port int, username string, privateKey []byte) error {
logging.Debug("Testing SSH auth for %s@%s:%d", username, hostname, port)
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to parse master key: %w", err)
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", hostname, port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("SSH authentication failed: %w", err)
}
client.Close()
return nil
}
// logDeployment records a deployment attempt
func (s *Service) logDeployment(keyID, serverID int64, status, message string) {
s.db.Exec(
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message) VALUES (?, ?, ?, ?)`,
keyID, serverID, status, message,
)
}
// GetDeployments returns deployment history for a user's keys
func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error) {
rows, err := s.db.Query(
`SELECT kd.id, sk.name as key_name, srv.name as server_name, kd.status, kd.message, kd.deployed_at
FROM key_deployments kd
JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
JOIN servers srv ON kd.server_id = srv.id
WHERE sk.user_id = ?
ORDER BY kd.deployed_at DESC LIMIT 50`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query deployments: %w", err)
}
defer rows.Close()
var deployments []map[string]interface{}
for rows.Next() {
var id int64
var keyName, serverName, status, message string
var deployedAt time.Time
if err := rows.Scan(&id, &keyName, &serverName, &status, &message, &deployedAt); err != nil {
continue
}
deployments = append(deployments, map[string]interface{}{
"id": id,
"key_name": keyName,
"server_name": serverName,
"status": status,
"message": message,
"deployed_at": deployedAt,
})
}
return deployments, nil
}
+79
View File
@@ -0,0 +1,79 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package encryption
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
)
// Service handles AES-256-GCM encryption/decryption
type Service struct {
key []byte // 32 bytes for AES-256
}
// NewService creates a new encryption service from a passphrase
func NewService(passphrase string) *Service {
// Derive a 32-byte key from the passphrase using SHA-256
hash := sha256.Sum256([]byte(passphrase))
return &Service{key: hash[:]}
}
// Encrypt encrypts plaintext using AES-256-GCM and returns base64-encoded ciphertext
func (s *Service) Encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts base64-encoded AES-256-GCM ciphertext
func (s *Service) Decrypt(encoded string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("failed to decode base64: %w", err)
}
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return string(plaintext), nil
}
+132
View File
@@ -0,0 +1,132 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package encryption
import (
"strings"
"testing"
)
func TestNewService(t *testing.T) {
svc := NewService("test-passphrase")
if svc == nil {
t.Fatal("NewService returned nil")
}
if len(svc.key) != 32 {
t.Fatalf("expected 32-byte key, got %d", len(svc.key))
}
}
func TestEncryptDecryptRoundtrip(t *testing.T) {
svc := NewService("my-secret-passphrase-32-chars!!")
plaintext := "This is a private SSH key content"
encrypted, err := svc.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
if encrypted == plaintext {
t.Fatal("Encrypted text should differ from plaintext")
}
decrypted, err := svc.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted != plaintext {
t.Fatalf("Decrypted text mismatch: got %q, want %q", decrypted, plaintext)
}
}
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
svc := NewService("test-passphrase")
plaintext := "same input"
enc1, err := svc.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt 1 failed: %v", err)
}
enc2, err := svc.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt 2 failed: %v", err)
}
if enc1 == enc2 {
t.Fatal("Two encryptions of the same plaintext should produce different ciphertexts (random nonce)")
}
}
func TestDecryptWithWrongKey(t *testing.T) {
svc1 := NewService("correct-passphrase")
svc2 := NewService("wrong-passphrase")
encrypted, err := svc1.Encrypt("secret data")
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
_, err = svc2.Decrypt(encrypted)
if err == nil {
t.Fatal("Decrypt with wrong key should fail")
}
}
func TestDecryptInvalidBase64(t *testing.T) {
svc := NewService("test")
_, err := svc.Decrypt("not-valid-base64!!!")
if err == nil {
t.Fatal("Decrypt of invalid base64 should fail")
}
}
func TestDecryptTooShort(t *testing.T) {
svc := NewService("test")
// Valid base64 but too short for nonce + ciphertext
_, err := svc.Decrypt("AQID")
if err == nil {
t.Fatal("Decrypt of too-short ciphertext should fail")
}
}
func TestEncryptEmptyString(t *testing.T) {
svc := NewService("test")
encrypted, err := svc.Encrypt("")
if err != nil {
t.Fatalf("Encrypt empty string failed: %v", err)
}
decrypted, err := svc.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt empty string failed: %v", err)
}
if decrypted != "" {
t.Fatalf("Expected empty string, got %q", decrypted)
}
}
func TestEncryptLargePayload(t *testing.T) {
svc := NewService("test")
// Simulate a large SSH private key
large := strings.Repeat("A", 4096)
encrypted, err := svc.Encrypt(large)
if err != nil {
t.Fatalf("Encrypt large payload failed: %v", err)
}
decrypted, err := svc.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt large payload failed: %v", err)
}
if decrypted != large {
t.Fatal("Large payload roundtrip mismatch")
}
}
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package keys
import (
"fmt"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/encryption"
"git.techniverse.net/scriptos/keywarden/internal/models"
"git.techniverse.net/scriptos/keywarden/internal/sshutil"
)
// Service handles SSH key operations
type Service struct {
db *database.DB
enc *encryption.Service
}
// NewService creates a new key service with encryption
func NewService(db *database.DB, enc *encryption.Service) *Service {
return &Service{db: db, enc: enc}
}
// GenerateKey generates a new SSH key pair and stores it encrypted
func (s *Service) GenerateKey(userID int64, name, keyType string, bits int, comment string) (*models.SSHKey, error) {
return s.generateKey(userID, name, keyType, bits, comment)
}
// generateKey is the internal key generation function
func (s *Service) generateKey(userID int64, name, keyType string, bits int, comment string) (*models.SSHKey, error) {
var privPEM, pubKey []byte
var fingerprint string
var err error
switch keyType {
case "rsa":
privPEM, pubKey, fingerprint, err = sshutil.GenerateRSAKey(bits, comment)
case "ed25519":
privPEM, pubKey, fingerprint, err = sshutil.GenerateEd25519Key(comment)
bits = 256
case "ed448":
privPEM, pubKey, fingerprint, err = sshutil.GenerateEd448Key(comment)
bits = 456
default:
return nil, fmt.Errorf("unsupported key type: %s", keyType)
}
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
// Encrypt private key before storage
encPrivKey, err := s.enc.Encrypt(string(privPEM))
if err != nil {
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
}
result, err := s.db.Exec(
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
userID, name, keyType, bits, fingerprint, string(pubKey), encPrivKey,
)
if err != nil {
return nil, fmt.Errorf("failed to store key: %w", err)
}
id, _ := result.LastInsertId()
return &models.SSHKey{
ID: id,
UserID: userID,
Name: name,
KeyType: keyType,
Bits: bits,
Fingerprint: fingerprint,
PublicKey: string(pubKey),
}, nil
}
// ImportKey imports an existing key pair (encrypts the private key)
func (s *Service) ImportKey(userID int64, name string, privateKeyPEM []byte) (*models.SSHKey, error) {
pubKey, fingerprint, keyType, err := sshutil.ParsePrivateKey(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
// Map SSH key type names
kt := "rsa"
bits := 0
switch keyType {
case "ssh-rsa":
kt = "rsa"
bits = 2048 // approximate
case "ssh-ed25519":
kt = "ed25519"
bits = 256
case "ssh-ed448":
kt = "ed448"
bits = 456
}
// Encrypt private key before storage
encPrivKey, err := s.enc.Encrypt(string(privateKeyPEM))
if err != nil {
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
}
result, err := s.db.Exec(
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
userID, name, kt, bits, fingerprint, string(pubKey), encPrivKey,
)
if err != nil {
return nil, fmt.Errorf("failed to store key: %w", err)
}
id, _ := result.LastInsertId()
return &models.SSHKey{
ID: id,
UserID: userID,
Name: name,
KeyType: kt,
Bits: bits,
Fingerprint: fingerprint,
PublicKey: string(pubKey),
}, nil
}
// GetKeysByUser returns all keys for a user
func (s *Service) GetKeysByUser(userID int64) ([]models.SSHKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
FROM ssh_keys WHERE user_id = ? ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query keys: %w", err)
}
defer rows.Close()
var keys []models.SSHKey
for rows.Next() {
var k models.SSHKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
keys = append(keys, k)
}
return keys, nil
}
// GetAllKeys returns all SSH keys for all users (admin use)
func (s *Service) GetAllKeys() ([]models.SSHKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
FROM ssh_keys ORDER BY user_id, name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all keys: %w", err)
}
defer rows.Close()
var keys []models.SSHKey
for rows.Next() {
var k models.SSHKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
keys = append(keys, k)
}
return keys, nil
}
// GetKeyByID returns a specific key with decrypted private key
func (s *Service) GetKeyByID(keyID, userID int64) (*models.SSHKey, error) {
key := &models.SSHKey{}
var encPrivKey string
err := s.db.QueryRow(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, created_at
FROM ssh_keys WHERE id = ? AND user_id = ?`, keyID, userID,
).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyType, &key.Bits, &key.Fingerprint, &key.PublicKey, &encPrivKey, &key.CreatedAt)
if err != nil {
return nil, fmt.Errorf("key not found: %w", err)
}
// Decrypt private key
decrypted, err := s.enc.Decrypt(encPrivKey)
if err != nil {
// Fallback: might be an old unencrypted key
key.PrivateKeyEnc = encPrivKey
} else {
key.PrivateKeyEnc = decrypted
}
return key, nil
}
// DeleteKey deletes a key
func (s *Service) DeleteKey(keyID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM ssh_keys WHERE id = ? AND user_id = ?`, keyID, userID)
if err != nil {
return fmt.Errorf("failed to delete key: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("key not found")
}
return nil
}
// GetAllKeysWithOwner returns all SSH keys with their owner's username (for admin/owner views)
func (s *Service) GetAllKeysWithOwner() ([]models.SSHKeyWithOwner, error) {
rows, err := s.db.Query(
`SELECT k.id, k.user_id, k.name, k.key_type, k.bits, k.fingerprint, k.public_key, k.created_at,
COALESCE(u.username, '(deleted)')
FROM ssh_keys k
LEFT JOIN users u ON k.user_id = u.id
ORDER BY u.username ASC, k.name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all keys with owner: %w", err)
}
defer rows.Close()
var keys []models.SSHKeyWithOwner
for rows.Next() {
var k models.SSHKeyWithOwner
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt, &k.OwnerUsername); err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
keys = append(keys, k)
}
return keys, nil
}
// GetKeyByIDGlobal returns a specific key without user_id check (admin/owner access)
// Note: Private key is NOT returned decrypted — only metadata and public key
func (s *Service) GetKeyByIDGlobal(keyID int64) (*models.SSHKey, error) {
key := &models.SSHKey{}
err := s.db.QueryRow(
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
FROM ssh_keys WHERE id = ?`, keyID,
).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyType, &key.Bits, &key.Fingerprint, &key.PublicKey, &key.CreatedAt)
if err != nil {
return nil, fmt.Errorf("key not found: %w", err)
}
return key, nil
}
// DeleteKeyGlobal deletes a key without user_id check (admin/owner access)
func (s *Service) DeleteKeyGlobal(keyID int64) error {
result, err := s.db.Exec(`DELETE FROM ssh_keys WHERE id = ?`, keyID)
if err != nil {
return fmt.Errorf("failed to delete key: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("key not found")
}
return nil
}
// --- System Master Key ---
// The system master key is an Ed25519 key pair used by Keywarden to authenticate
// against remote servers for key deployments. It is generated once on first startup
// and stored encrypted in the settings table. It cannot be deleted, only regenerated.
// EnsureSystemMasterKey generates the system master key if it doesn't exist yet.
// Returns the public key string.
func (s *Service) EnsureSystemMasterKey() (string, error) {
pub, err := s.getSetting("system_master_key_public")
if err == nil && pub != "" {
return pub, nil
}
// Generate new master key
return s.generateSystemMasterKey()
}
// generateSystemMasterKey generates a new Ed25519 key pair and stores it in settings.
func (s *Service) generateSystemMasterKey() (string, error) {
privPEM, pubKey, fingerprint, err := sshutil.GenerateEd25519Key("keywarden-system-master")
if err != nil {
return "", fmt.Errorf("failed to generate system master key: %w", err)
}
// Encrypt private key
encPriv, err := s.enc.Encrypt(string(privPEM))
if err != nil {
return "", fmt.Errorf("failed to encrypt system master key: %w", err)
}
pubStr := string(pubKey)
// Store in settings table
if err := s.setSetting("system_master_key_private", encPriv); err != nil {
return "", fmt.Errorf("failed to store system master key private: %w", err)
}
if err := s.setSetting("system_master_key_public", pubStr); err != nil {
return "", fmt.Errorf("failed to store system master key public: %w", err)
}
if err := s.setSetting("system_master_key_fingerprint", fingerprint); err != nil {
return "", fmt.Errorf("failed to store system master key fingerprint: %w", err)
}
return pubStr, nil
}
// GetSystemMasterKeyPublic returns the public key of the system master key.
func (s *Service) GetSystemMasterKeyPublic() (string, error) {
pub, err := s.getSetting("system_master_key_public")
if err != nil || pub == "" {
return "", fmt.Errorf("system master key not found")
}
return pub, nil
}
// GetSystemMasterKeyFingerprint returns the fingerprint of the system master key.
func (s *Service) GetSystemMasterKeyFingerprint() (string, error) {
fp, err := s.getSetting("system_master_key_fingerprint")
if err != nil || fp == "" {
return "", fmt.Errorf("system master key fingerprint not found")
}
return fp, nil
}
// GetSystemMasterKeyPrivate returns the decrypted private key PEM of the system master key.
func (s *Service) GetSystemMasterKeyPrivate() ([]byte, error) {
encPriv, err := s.getSetting("system_master_key_private")
if err != nil || encPriv == "" {
return nil, fmt.Errorf("system master key not found")
}
decrypted, err := s.enc.Decrypt(encPriv)
if err != nil {
return nil, fmt.Errorf("failed to decrypt system master key: %w", err)
}
return []byte(decrypted), nil
}
// RegenerateSystemMasterKey generates a new system master key, replacing the old one.
// Returns the new public key string.
func (s *Service) RegenerateSystemMasterKey() (string, error) {
return s.generateSystemMasterKey()
}
// getSetting reads a value from the settings table.
func (s *Service) getSetting(key string) (string, error) {
var value string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
// setSetting writes a value to the settings table (upsert).
func (s *Service) setSetting(key, value string) error {
_, err := 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,
)
return err
}
// EncryptValue encrypts a plaintext string using the application encryption key
func (s *Service) EncryptValue(plaintext string) (string, error) {
return s.enc.Encrypt(plaintext)
}
// DecryptValue decrypts an encrypted string using the application encryption key
func (s *Service) DecryptValue(ciphertext string) (string, error) {
return s.enc.Decrypt(ciphertext)
}
+251
View File
@@ -0,0 +1,251 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package logging
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
// Level represents the severity of a log message
type Level int
const (
LevelError Level = iota // only errors
LevelWarn // errors + warnings
LevelInfo // errors + warnings + info (default)
LevelDebug // errors + warnings + info + debug
LevelTrace // everything, including very verbose trace output
)
// String returns the human-readable name of the log level
func (l Level) String() string {
switch l {
case LevelError:
return "ERROR"
case LevelWarn:
return "WARN"
case LevelInfo:
return "INFO"
case LevelDebug:
return "DEBUG"
case LevelTrace:
return "TRACE"
default:
return "UNKNOWN"
}
}
// Logger is the application-wide structured logger
type Logger struct {
level Level
}
// global singleton initialised via Init()
var global *Logger
func init() {
// Set log flags for consistent timestamp output (goes to stdout/stderr → Docker logs)
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
log.SetOutput(os.Stdout)
// Default until Init() is called
global = &Logger{level: LevelInfo}
}
// Init creates the global logger from the KEYWARDEN_LOG_LEVEL env var.
// Valid values: ERROR, WARN, INFO (default), DEBUG, TRACE
func Init(envValue string) {
global = &Logger{level: ParseLevel(envValue)}
global.Info("Log level set to %s", global.level.String())
}
// ParseLevel converts a string to a Level. Defaults to INFO on unknown input.
func ParseLevel(s string) Level {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "ERROR":
return LevelError
case "WARN", "WARNING":
return LevelWarn
case "INFO", "":
return LevelInfo
case "DEBUG":
return LevelDebug
case "TRACE":
return LevelTrace
default:
return LevelInfo
}
}
// GetLevel returns the current global log level
func GetLevel() Level {
return global.level
}
// ---------------------------------------------------------------------------
// Core logging methods
// ---------------------------------------------------------------------------
func (l *Logger) log(lvl Level, format string, args ...interface{}) {
if lvl > l.level {
return
}
msg := fmt.Sprintf(format, args...)
log.Printf("[%-5s] %s", lvl.String(), msg)
}
// Error logs at ERROR level (always shown)
func Error(format string, args ...interface{}) { global.log(LevelError, format, args...) }
// Warn logs at WARN level
func Warn(format string, args ...interface{}) { global.log(LevelWarn, format, args...) }
// Info logs at INFO level
func Info(format string, args ...interface{}) { global.log(LevelInfo, format, args...) }
// Debug logs at DEBUG level
func Debug(format string, args ...interface{}) { global.log(LevelDebug, format, args...) }
// Trace logs at TRACE level (very verbose)
func Trace(format string, args ...interface{}) { global.log(LevelTrace, format, args...) }
// Fatal logs at ERROR level and exits the process (like log.Fatalf)
func Fatal(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Fatalf("[ERROR] %s", msg)
}
// ---------------------------------------------------------------------------
// Convenience helpers
// ---------------------------------------------------------------------------
// Error-returning variant for wrapping + logging in one call
func (l *Logger) Info(format string, args ...interface{}) {
l.log(LevelInfo, format, args...)
}
// ---------------------------------------------------------------------------
// HTTP Request Logging Middleware
// ---------------------------------------------------------------------------
// responseWriter wraps http.ResponseWriter to capture the status code and bytes written
type responseWriter struct {
http.ResponseWriter
statusCode int
bytesWritten int
}
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytesWritten += n
return n, err
}
// RequestLogger returns middleware that logs every HTTP request.
// Output format:
//
// [INFO ] HTTP | 200 | 12.34ms | 192.168.1.1 | GET /dashboard | user=admin | Mozilla/5.0 ...
//
// At DEBUG level it additionally logs request headers.
// At TRACE level it logs everything including cookies (except values).
//
// An optional clientIPFunc can be provided to customise IP extraction
// (e.g. using trusted-proxy-aware logic). If omitted, the built-in
// extractClientIP is used.
func RequestLogger(getUserName func(r *http.Request) string, clientIPFunc ...func(r *http.Request) string) func(http.Handler) http.Handler {
getIP := extractClientIP
if len(clientIPFunc) > 0 && clientIPFunc[0] != nil {
getIP = clientIPFunc[0]
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip logging for static assets at INFO level to reduce noise
isStatic := strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/avatar/")
start := time.Now()
wrapped := newResponseWriter(w)
// Process the request
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
// Determine client IP
clientIP := getIP(r)
// Determine username (empty string if not authenticated)
username := ""
if getUserName != nil {
username = getUserName(r)
}
userAgent := r.UserAgent()
if len(userAgent) > 120 {
userAgent = userAgent[:120] + "…"
}
// Build the log line
userPart := ""
if username != "" {
userPart = fmt.Sprintf(" | user=%s", username)
}
// Static assets: only log at DEBUG or higher
if isStatic {
Debug("HTTP | %d | %10v | %-15s | %s %s%s | %s",
wrapped.statusCode, duration.Round(time.Microsecond),
clientIP, r.Method, r.URL.Path, userPart, userAgent)
} else {
Info("HTTP | %d | %10v | %-15s | %s %s%s | %s",
wrapped.statusCode, duration.Round(time.Microsecond),
clientIP, r.Method, r.URL.Path, userPart, userAgent)
}
// At TRACE level, log response size and all request headers
if GetLevel() >= LevelTrace {
Trace("HTTP response: %d bytes written for %s %s", wrapped.bytesWritten, r.Method, r.URL.Path)
Trace("HTTP request headers for %s %s:", r.Method, r.URL.Path)
for name, values := range r.Header {
// Redact sensitive headers
if strings.EqualFold(name, "Cookie") || strings.EqualFold(name, "Authorization") {
Trace(" %s: [REDACTED]", name)
} else {
Trace(" %s: %s", name, strings.Join(values, ", "))
}
}
}
})
}
}
// extractClientIP gets the real client IP, respecting reverse proxy headers
func extractClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.SplitN(fwd, ",", 2)
return strings.TrimSpace(parts[0])
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
// r.RemoteAddr is "ip:port"
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}
+501
View File
@@ -0,0 +1,501 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package mail
import (
"bytes"
"crypto/tls"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"text/template"
"time"
"git.techniverse.net/scriptos/keywarden/internal/config"
"git.techniverse.net/scriptos/keywarden/internal/logging"
)
// LoginNotificationData holds the template data for a login notification email
type LoginNotificationData struct {
Username string
IPAddress string
Timestamp string
UserAgent string
}
// InvitationData holds the template data for an invitation email
type InvitationData struct {
Username string
InviteURL string
ExpiresIn string
}
// Service handles email sending
type Service struct {
cfg *config.Config
enabled bool
}
// NewService creates a new mail service
func NewService(cfg *config.Config) *Service {
enabled := cfg.SMTPEnabled && cfg.SMTPHost != ""
if enabled {
logging.Info("Email notifications enabled (SMTP: %s:%s)", cfg.SMTPHost, cfg.SMTPPort)
} else {
logging.Info("Email notifications disabled (no SMTP host configured)")
}
return &Service{
cfg: cfg,
enabled: enabled,
}
}
// IsEnabled returns whether the mail service is configured and active
func (s *Service) IsEnabled() bool {
return s.enabled
}
// SendLoginNotification sends a login notification email to the user.
// This runs synchronously but callers should invoke it in a goroutine.
func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationData) error {
if !s.enabled {
return nil
}
htmlBody, err := renderTemplate(loginNotificationHTML, data)
if err != nil {
return fmt.Errorf("failed to render HTML template: %w", err)
}
txtBody, err := renderTemplate(loginNotificationTXT, data)
if err != nil {
return fmt.Errorf("failed to render TXT template: %w", err)
}
subject := fmt.Sprintf("Keywarden: Login notification for %s", data.Username)
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
}
// SendTestEmail sends a test email to verify SMTP configuration
func (s *Service) SendTestEmail(toEmail string) error {
if !s.enabled {
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
}
subject := "Keywarden: SMTP Test Email"
txtBody := "This is a test email from Keywarden.\n\nIf you received this, your SMTP configuration is working correctly.\n"
htmlBody := `<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f1f5f9;">
<div style="max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #206bc4; margin-top: 0;">&#x1F511; Keywarden SMTP Test</h2>
<p>This is a test email from Keywarden.</p>
<p style="color: #4caf50; font-weight: bold;">&#x2705; Your SMTP configuration is working correctly.</p>
</div>
</body>
</html>`
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
}
// SendInvitation sends an invitation email with a registration link to a new user.
func (s *Service) SendInvitation(toEmail string, data InvitationData) error {
if !s.enabled {
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
}
htmlBody, err := renderTemplate(invitationHTML, data)
if err != nil {
return fmt.Errorf("failed to render invitation HTML template: %w", err)
}
txtBody, err := renderTemplate(invitationTXT, data)
if err != nil {
return fmt.Errorf("failed to render invitation TXT template: %w", err)
}
subject := fmt.Sprintf("Keywarden: You have been invited %s", data.Username)
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
}
// sendMultipart sends a multipart (text + HTML) email
func (s *Service) sendMultipart(to, subject, textBody, htmlBody string) error {
logging.Info("Sending email: to=%s subject='%s' smtp=%s:%s", to, subject, s.cfg.SMTPHost, s.cfg.SMTPPort)
logging.Debug("Email details: from=%s tls=%v", s.cfg.SMTPFrom, s.cfg.SMTPTLS)
boundary := fmt.Sprintf("keywarden-%d", time.Now().UnixNano())
// Encode Subject per RFC 2047 so non-ASCII characters (e.g. en-dash)
// are transmitted safely through all MTAs.
encodedSubject := mime.QEncoding.Encode("utf-8", subject)
headers := map[string]string{
"From": s.cfg.SMTPFrom,
"To": to,
"Subject": encodedSubject,
"MIME-Version": "1.0",
"Content-Type": fmt.Sprintf("multipart/alternative; boundary=\"%s\"", boundary),
"Date": time.Now().Format(time.RFC1123Z),
"X-Mailer": "Keywarden SSH Key Management",
}
var msg bytes.Buffer
for k, v := range headers {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
// Text part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
msg.WriteString(textBody)
msg.WriteString("\r\n")
// HTML part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n")
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
msg.WriteString(htmlBody)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
err := s.send(to, msg.Bytes())
if err != nil {
logging.Error("Email delivery failed: to=%s error=%v", to, err)
} else {
logging.Info("Email delivered successfully: to=%s", to)
}
return err
}
// send delivers a raw email message via SMTP
func (s *Service) send(to string, msg []byte) error {
addr := net.JoinHostPort(s.cfg.SMTPHost, s.cfg.SMTPPort)
var auth smtp.Auth
if s.cfg.SMTPUser != "" {
auth = smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPassword, s.cfg.SMTPHost)
}
if s.cfg.SMTPTLS {
// STARTTLS or implicit TLS
tlsConfig := &tls.Config{
ServerName: s.cfg.SMTPHost,
MinVersion: tls.VersionTLS12,
}
// Try implicit TLS first (port 465), fall back to STARTTLS
if s.cfg.SMTPPort == "465" {
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
// STARTTLS (port 587 etc.)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("STARTTLS failed: %w", err)
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
// Plain SMTP (no TLS) use manual client to avoid Go's smtp.SendMail
// automatically attempting STARTTLS when the server advertises it.
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
}
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
func renderTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := template.New("email").Parse(tmplStr)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// --- Email Templates ---
var loginNotificationHTML = strings.TrimSpace(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">&#x1F511; Keywarden</h1>
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">Login Notification</h2>
<p style="color: #475569; line-height: 1.6;">
A successful login to your Keywarden account was detected:
</p>
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">User</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">IP Address</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.IPAddress}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Time</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Timestamp}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Browser</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.UserAgent}}</td>
</tr>
</table>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
&#x26A0;&#xFE0F; <strong>If this was not you</strong>, please change your password immediately and review your account security settings.
</p>
</div>
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
You received this email because login notifications are enabled for your account. You can disable them in the Settings page.
</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
&copy; 2026 Keywarden &ndash; Centralized SSH Key Management and Deployment
</div>
</div>
</body>
</html>
`)
var loginNotificationTXT = strings.TrimSpace(`
Keywarden - Login Notification
============================
A successful login to your Keywarden account was detected:
User: {{.Username}}
IP Address: {{.IPAddress}}
Time: {{.Timestamp}}
Browser: {{.UserAgent}}
If this was not you, please change your password immediately
and review your account security settings.
--
You received this email because login notifications are enabled
for your account. You can disable them in the Settings page.
Keywarden - Centralized SSH Key Management and Deployment
`)
var invitationHTML = strings.TrimSpace(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">&#x1F511; Keywarden</h1>
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">You have been invited!</h2>
<p style="color: #475569; line-height: 1.6;">
An account has been created for you in Keywarden. Please complete your registration by setting a password.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">Username</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Valid for</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.ExpiresIn}}</td>
</tr>
</table>
<div style="text-align: center; margin: 32px 0;">
<a href="{{.InviteURL}}" style="display: inline-block; background: #206bc4; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
&#x1F680; Complete Registration
</a>
</div>
<div style="background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #0369a1; font-size: 14px;">
&#x1F6C8; If the button does not work, copy and paste this link into your browser:
</p>
<p style="margin: 8px 0 0; color: #0369a1; font-size: 12px; word-break: break-all;">{{.InviteURL}}</p>
</div>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
&#x26A0;&#xFE0F; <strong>This link is valid for {{.ExpiresIn}} and can only be used once.</strong> If you did not expect this invitation, you can safely ignore this email.
</p>
</div>
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
This is an automated invitation from Keywarden.
</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
&copy; 2026 Keywarden &ndash; Centralized SSH Key Management and Deployment
</div>
</div>
</body>
</html>
`)
var invitationTXT = strings.TrimSpace(`
Keywarden - Invitation
======================
You have been invited to Keywarden!
An account has been created for you. Please complete your registration
by setting a password.
Username: {{.Username}}
Valid for: {{.ExpiresIn}}
Complete your registration here:
{{.InviteURL}}
This link is valid for {{.ExpiresIn}} and can only be used once.
If you did not expect this invitation, you can safely ignore this email.
--
Keywarden - Centralized SSH Key Management and Deployment
`)
+180
View File
@@ -0,0 +1,180 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package models
import "time"
// User represents a registered user
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Role string `json:"role"`
MFAEnabled bool `json:"mfa_enabled"`
MFASecret string `json:"-"`
Theme string `json:"theme"` // "auto", "light", "dark"
EmailNotifyLogin bool `json:"email_notify_login"`
AvatarBase64 string `json:"avatar_base64"` // base64-encoded profile picture (data URI)
MustChangePassword bool `json:"must_change_password"` // force password change on next login
FailedLoginAttempts int `json:"failed_login_attempts"` // consecutive failed login attempts
LockedUntil *time.Time `json:"locked_until"` // account locked until this time
LastLoginAt *time.Time `json:"last_login_at"` // last successful login
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PasswordPolicy holds the password complexity requirements
type PasswordPolicy struct {
MinLength int `json:"min_length"`
RequireUpper bool `json:"require_upper"`
RequireLower bool `json:"require_lower"`
RequireDigit bool `json:"require_digit"`
RequireSpecial bool `json:"require_special"`
}
// SSHKey represents a stored SSH key pair
type SSHKey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
KeyType string `json:"key_type"` // "rsa", "ed25519", or "ed448"
Bits int `json:"bits"` // 2048, 4096 for RSA; 256 for Ed25519; 456 for Ed448
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
PrivateKeyEnc string `json:"-"` // encrypted private key
PassphraseEnc string `json:"-"` // encrypted passphrase (optional)
CreatedAt time.Time `json:"created_at"`
}
// SSHKeyWithOwner extends SSHKey with the owner's username for admin views
type SSHKeyWithOwner struct {
SSHKey
OwnerUsername string `json:"owner_username"`
}
// Server represents a remote SSH server
type Server struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Username string `json:"username"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// KeyDeployment represents a key deployed to a server
type KeyDeployment struct {
ID int64 `json:"id"`
SSHKeyID int64 `json:"ssh_key_id"`
ServerID int64 `json:"server_id"`
DeployedAt time.Time `json:"deployed_at"`
Status string `json:"status"` // "pending", "success", "failed"
Message string `json:"message"`
}
// ServerGroup represents a group of servers
type ServerGroup struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ServerGroupWithCount extends ServerGroup with the number of member servers
type ServerGroupWithCount struct {
ServerGroup
ServerCount int `json:"server_count"`
}
// AuditLog represents an audit trail entry
type AuditLog struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Action string `json:"action"`
Details string `json:"details"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
}
// CronJob represents a scheduled temporary access job
type CronJob struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
SSHKeyID int64 `json:"ssh_key_id"`
ServerID int64 `json:"server_id"` // 0 if targeting a group
GroupID int64 `json:"group_id"` // 0 if targeting a single server
Schedule string `json:"schedule"` // "once", "hourly", "daily", "weekly", "monthly"
ScheduledAt time.Time `json:"scheduled_at"`
NextRun time.Time `json:"next_run"`
LastRun *time.Time `json:"last_run"`
RemoveAfterMin int `json:"remove_after_min"` // 0 = permanent
Status string `json:"status"` // "active", "paused", "running", "done", "failed"
Message string `json:"message"`
Timezone string `json:"timezone"` // IANA timezone, e.g. "Europe/Berlin"
TimeOfDay string `json:"time_of_day"` // "HH:MM" for daily/weekly/monthly
DayOfWeek int `json:"day_of_week"` // 0=Sunday..6=Saturday (-1=unset)
DayOfMonth int `json:"day_of_month"` // 1-31 (0=unset)
MinuteOfHour int `json:"minute_of_hour"` // 0-59 for hourly schedule
TargetUserID int64 `json:"target_user_id"` // KeyWarden user to grant access
SystemUser string `json:"system_user"` // system user on target host
Sudo bool `json:"sudo"` // grant sudo rights
CreateUser bool `json:"create_user"` // create system user if missing
InitialPassword string `json:"initial_password"` // encrypted initial password for created user
ExpiryAction string `json:"expiry_action"` // "remove_key", "disable_user", "delete_user"
CreatedAt time.Time `json:"created_at"`
}
// CronJobDisplay extends CronJob with resolved names for UI display
type CronJobDisplay struct {
CronJob
KeyName string `json:"key_name"`
TargetName string `json:"target_name"`
TargetType string `json:"target_type"` // "host" or "group"
TargetUsername string `json:"target_username"` // KeyWarden username
}
// AccessAssignment represents an access assignment (user+key → host/group)
type AccessAssignment struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"` // Keywarden user
SSHKeyID int64 `json:"ssh_key_id"` // SSH key to deploy
ServerID int64 `json:"server_id"` // target host (0 if group)
GroupID int64 `json:"group_id"` // target group (0 if single host)
SystemUser string `json:"system_user"` // system user on target host
DesiredState string `json:"desired_state"` // "present" or "absent"
Sudo bool `json:"sudo"` // grant sudo rights
CreateUser bool `json:"create_user"` // create system user if missing
InitialPassword string `json:"initial_password"` // encrypted initial password for created user
Status string `json:"status"` // "pending", "synced", "failed"
LastSyncAt *time.Time `json:"last_sync_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AccessAssignmentDisplay extends AccessAssignment with resolved names for UI
type AccessAssignmentDisplay struct {
AccessAssignment
Username string `json:"username"`
KeyName string `json:"key_name"`
TargetName string `json:"target_name"`
TargetType string `json:"target_type"` // "host" or "group"
}
// InvitationToken represents a one-time invitation link for a new user
type InvitationToken struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Used bool `json:"used"`
CreatedAt time.Time `json:"created_at"`
}
+70
View File
@@ -0,0 +1,70 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"net/http"
)
// CSRFMiddleware returns middleware that implements the Double-Submit Cookie
// pattern for CSRF protection.
//
// On every request a _csrf cookie is ensured (generated if absent). On
// state-changing methods (POST, PUT, DELETE, PATCH) the middleware validates
// that the request carries a matching token either as:
// - a form field named "_csrf", or
// - an X-CSRF-Token request header (for AJAX / fetch calls).
//
// The cookie is NOT HttpOnly so that client-side JavaScript can read the
// value and inject it into forms / fetch headers automatically.
func CSRFMiddleware(secureCookies bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// --- Ensure CSRF token exists ---
token := ""
if c, err := r.Cookie("_csrf"); err == nil && len(c.Value) == 64 {
token = c.Value
}
if token == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
token = fmt.Sprintf("%x", b)
http.SetCookie(w, &http.Cookie{
Name: "_csrf",
Value: token,
Path: "/",
HttpOnly: false, // JS must be able to read it
Secure: secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
})
}
// --- Validate on state-changing methods ---
if r.Method == http.MethodPost || r.Method == http.MethodPut ||
r.Method == http.MethodDelete || r.Method == http.MethodPatch {
// Accept the token from either the form body or a request header
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
http.Error(w, "Forbidden invalid or missing CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}
+65
View File
@@ -0,0 +1,65 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
)
// HeadersMiddleware returns middleware that sets security-relevant HTTP
// response headers on every response.
//
// These headers protect against clickjacking, MIME-sniffing, information
// leakage and other common web attacks. They are set regardless of
// whether TLS is in use.
func HeadersMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent the page from being embedded in an iframe (clickjacking)
h.Set("X-Frame-Options", "DENY")
// Stop browsers from MIME-sniffing the content type
h.Set("X-Content-Type-Options", "nosniff")
// Control what information is sent in the Referer header
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Restrict browser features that are not needed
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
// Content Security Policy fairly strict but allows inline
// styles/scripts that Tabler and the app currently use.
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none'; "+
"form-action 'self'; "+
"base-uri 'self'")
// Opt out of Google FLoC / Topics
h.Set("X-Permitted-Cross-Domain-Policies", "none")
// Cache control for authenticated pages prevent caching of
// sensitive data. Static assets set their own cache headers.
if r.URL.Path != "" && !isStaticAsset(r.URL.Path) {
h.Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
h.Set("Pragma", "no-cache")
}
next.ServeHTTP(w, r)
})
}
}
// isStaticAsset returns true for paths that serve static files.
func isStaticAsset(path string) bool {
return len(path) > 8 && (path[:8] == "/static/" || path[:8] == "/avatar/")
}
+112
View File
@@ -0,0 +1,112 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net"
"net/http"
"strings"
)
// trustedNets holds the parsed trusted proxy CIDR ranges.
// Set once at startup via Init().
var trustedNets []*net.IPNet
// Init parses the trusted proxy configuration and prepares the package
// for use. Must be called once at startup before any middleware runs.
//
// trustedProxies is a comma-separated list of CIDRs or IPs, e.g.
// "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16" or "10.0.1.92".
// An empty string means no trusted proxies are configured (legacy mode:
// proxy headers are trusted unconditionally for backward compatibility).
func Init(trustedProxies string) {
trustedNets = nil
if trustedProxies == "" {
return
}
for _, entry := range strings.Split(trustedProxies, ",") {
cidr := strings.TrimSpace(entry)
if cidr == "" {
continue
}
// Plain IP → convert to single-host CIDR
if !strings.Contains(cidr, "/") {
if strings.Contains(cidr, ":") {
cidr += "/128"
} else {
cidr += "/32"
}
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue // skip invalid entries silently
}
trustedNets = append(trustedNets, ipNet)
}
}
// ClientIP extracts the real client IP address from the request.
//
// When trusted proxies are configured, X-Forwarded-For is walked from
// right to left and the first non-trusted IP is returned (secure approach).
// When no trusted proxies are configured, the legacy behavior is used
// (leftmost X-Forwarded-For entry, i.e. the value the first proxy saw).
func ClientIP(r *http.Request) string {
remoteIP := extractRemoteIP(r.RemoteAddr)
if len(trustedNets) > 0 {
// Strict mode: only honour proxy headers when the direct peer is trusted
if !isTrustedIP(remoteIP) {
return remoteIP
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.Split(fwd, ",")
// Walk right-to-left: rightmost untrusted IP is the real client
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip != "" && !isTrustedIP(ip) {
return ip
}
}
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
return remoteIP
}
// Legacy mode (no trusted proxies configured): trust headers as before
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.SplitN(fwd, ",", 2)
return strings.TrimSpace(parts[0])
}
if real := r.Header.Get("X-Real-Ip"); real != "" {
return real
}
return remoteIP
}
// extractRemoteIP strips the port from r.RemoteAddr.
func extractRemoteIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err == nil {
return host
}
return addr
}
// isTrustedIP checks if an IP is within any of the configured trusted networks.
func isTrustedIP(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, n := range trustedNets {
if n.Contains(parsed) {
return true
}
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
"strings"
"sync"
"time"
)
// rateLimiter implements a fixed-window IP-based rate limiter.
type rateLimiter struct {
mu sync.Mutex
clients map[string]*window
limit int
period time.Duration
}
type window struct {
count int
resetAt time.Time
}
func newRateLimiter(limit int, period time.Duration) *rateLimiter {
rl := &rateLimiter{
clients: make(map[string]*window),
limit: limit,
period: period,
}
// Background goroutine to evict expired entries (prevent memory leak)
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
rl.mu.Lock()
for ip, w := range rl.clients {
if now.After(w.resetAt) {
delete(rl.clients, ip)
}
}
rl.mu.Unlock()
}
}()
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
w, ok := rl.clients[ip]
if !ok || now.After(w.resetAt) {
rl.clients[ip] = &window{count: 1, resetAt: now.Add(rl.period)}
return true
}
w.count++
return w.count <= rl.limit
}
// RateLimitMiddleware returns middleware that rate-limits requests to the
// login endpoint by client IP address.
//
// loginLimit is the maximum number of login attempts per IP per minute.
// A value of 0 disables rate limiting entirely.
func RateLimitMiddleware(loginLimit int) func(http.Handler) http.Handler {
if loginLimit <= 0 {
// Disabled pass through
return func(next http.Handler) http.Handler { return next }
}
loginRL := newRateLimiter(loginLimit, 1*time.Minute)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only rate-limit POST to login endpoints
if r.Method == http.MethodPost && isLoginPath(r.URL.Path) {
ip := ClientIP(r)
if !loginRL.allow(ip) {
http.Error(w, "Too Many Requests please try again later", http.StatusTooManyRequests)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// isLoginPath returns true for login-related POST paths.
func isLoginPath(path string) bool {
return path == "/login" || strings.HasPrefix(path, "/login/")
}
+30
View File
@@ -0,0 +1,30 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package security
import (
"net/http"
)
// SizeLimitMiddleware returns middleware that limits the size of incoming
// request bodies to maxBytes. This prevents denial-of-service attacks via
// excessively large uploads. A value of 0 disables the limit.
//
// The limit is enforced with http.MaxBytesReader which causes the server
// to return 413 Request Entity Too Large if the body exceeds the limit.
func SizeLimitMiddleware(maxBytes int64) func(http.Handler) http.Handler {
if maxBytes <= 0 {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.ContentLength != 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
}
+776
View File
@@ -0,0 +1,776 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package servers
import (
"fmt"
"git.techniverse.net/scriptos/keywarden/internal/database"
"git.techniverse.net/scriptos/keywarden/internal/models"
)
// Service handles server management
type Service struct {
db *database.DB
}
// NewService creates a new server service
func NewService(db *database.DB) *Service {
return &Service{db: db}
}
// Create adds a new server
func (s *Service) Create(userID int64, name, hostname string, port int, username, description string) (*models.Server, error) {
if port == 0 {
port = 22
}
result, err := s.db.Exec(
`INSERT INTO servers (user_id, name, hostname, port, username, description) VALUES (?, ?, ?, ?, ?, ?)`,
userID, name, hostname, port, username, description,
)
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
id, _ := result.LastInsertId()
return &models.Server{
ID: id,
UserID: userID,
Name: name,
Hostname: hostname,
Port: port,
Username: username,
Description: description,
}, nil
}
// GetByUser returns all servers for a user
func (s *Service) GetByUser(userID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers WHERE user_id = ? ORDER BY name ASC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query servers: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetByID returns a specific server
func (s *Service) GetByID(serverID, userID int64) (*models.Server, error) {
srv := &models.Server{}
err := s.db.QueryRow(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers WHERE id = ? AND user_id = ?`, serverID, userID,
).Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server not found: %w", err)
}
return srv, nil
}
// Update modifies a server
func (s *Service) Update(serverID, userID int64, name, hostname string, port int, username, description string) error {
result, err := s.db.Exec(
`UPDATE servers SET name=?, hostname=?, port=?, username=?, description=?, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND user_id=?`,
name, hostname, port, username, description, serverID, userID,
)
if err != nil {
return fmt.Errorf("failed to update server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// Delete removes a server
func (s *Service) Delete(serverID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM servers WHERE id = ? AND user_id = ?`, serverID, userID)
if err != nil {
return fmt.Errorf("failed to delete server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// --- Server Groups ---
// CreateGroup creates a new server group
func (s *Service) CreateGroup(userID int64, name, description string) (*models.ServerGroup, error) {
result, err := s.db.Exec(
`INSERT INTO server_groups (user_id, name, description) VALUES (?, ?, ?)`,
userID, name, description,
)
if err != nil {
return nil, fmt.Errorf("failed to create server group: %w", err)
}
id, _ := result.LastInsertId()
return &models.ServerGroup{
ID: id,
UserID: userID,
Name: name,
Description: description,
}, nil
}
// GetGroupsByUser returns all server groups for a user with server counts
func (s *Service) GetGroupsByUser(userID int64) ([]models.ServerGroupWithCount, error) {
rows, err := s.db.Query(
`SELECT sg.id, sg.user_id, sg.name, sg.description, sg.created_at, sg.updated_at,
COUNT(sgm.server_id) as server_count
FROM server_groups sg
LEFT JOIN server_group_members sgm ON sg.id = sgm.group_id
WHERE sg.user_id = ?
GROUP BY sg.id
ORDER BY sg.name ASC`, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query server groups: %w", err)
}
defer rows.Close()
var groups []models.ServerGroupWithCount
for rows.Next() {
var g models.ServerGroupWithCount
if err := rows.Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt, &g.ServerCount); err != nil {
return nil, fmt.Errorf("failed to scan server group: %w", err)
}
groups = append(groups, g)
}
return groups, nil
}
// GetGroupByID returns a specific server group
func (s *Service) GetGroupByID(groupID, userID int64) (*models.ServerGroup, error) {
g := &models.ServerGroup{}
err := s.db.QueryRow(
`SELECT id, user_id, name, description, created_at, updated_at
FROM server_groups WHERE id = ? AND user_id = ?`, groupID, userID,
).Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server group not found: %w", err)
}
return g, nil
}
// UpdateGroup modifies a server group
func (s *Service) UpdateGroup(groupID, userID int64, name, description string) error {
result, err := s.db.Exec(
`UPDATE server_groups SET name=?, description=?, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND user_id=?`,
name, description, groupID, userID,
)
if err != nil {
return fmt.Errorf("failed to update server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// DeleteGroup removes a server group
func (s *Service) DeleteGroup(groupID, userID int64) error {
result, err := s.db.Exec(`DELETE FROM server_groups WHERE id = ? AND user_id = ?`, groupID, userID)
if err != nil {
return fmt.Errorf("failed to delete server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// AddServerToGroup adds a server to a group
func (s *Service) AddServerToGroup(groupID, serverID, userID int64) error {
// Verify group belongs to user
_, err := s.GetGroupByID(groupID, userID)
if err != nil {
return fmt.Errorf("group not found: %w", err)
}
// Verify server belongs to user
_, err = s.GetByID(serverID, userID)
if err != nil {
return fmt.Errorf("server not found: %w", err)
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
return nil
}
// RemoveServerFromGroup removes a server from a group
func (s *Service) RemoveServerFromGroup(groupID, serverID, userID int64) error {
// Verify group belongs to user
_, err := s.GetGroupByID(groupID, userID)
if err != nil {
return fmt.Errorf("group not found: %w", err)
}
_, err = s.db.Exec(
`DELETE FROM server_group_members WHERE group_id = ? AND server_id = ?`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to remove server from group: %w", err)
}
return nil
}
// AddServerToGroupGlobal adds a server to a group without user_id check
func (s *Service) AddServerToGroupGlobal(groupID, serverID int64) error {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
return nil
}
// RemoveServerFromGroupGlobal removes a server from a group without user_id check
func (s *Service) RemoveServerFromGroupGlobal(groupID, serverID int64) error {
_, err := s.db.Exec(
`DELETE FROM server_group_members WHERE group_id = ? AND server_id = ?`,
groupID, serverID,
)
if err != nil {
return fmt.Errorf("failed to remove server from group: %w", err)
}
return nil
}
// GetGroupMembers returns all servers in a group
func (s *Service) GetGroupMembers(groupID, userID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
FROM servers s
JOIN server_group_members sgm ON s.id = sgm.server_id
WHERE sgm.group_id = ? AND s.user_id = ?
ORDER BY s.name ASC`, groupID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query group members: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetGroupIDsForServer returns all group IDs that a server belongs to
func (s *Service) GetGroupIDsForServer(serverID, userID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT sgm.group_id FROM server_group_members sgm
JOIN server_groups sg ON sg.id = sgm.group_id
WHERE sgm.server_id = ? AND sg.user_id = ?`, serverID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query server groups: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// SetServerGroups replaces all group memberships for a server
func (s *Service) SetServerGroups(serverID, userID int64, groupIDs []int64) error {
// Verify server belongs to user
_, err := s.GetByID(serverID, userID)
if err != nil {
return fmt.Errorf("server not found: %w", err)
}
// Remove all current group memberships for this server
_, err = s.db.Exec(`DELETE FROM server_group_members WHERE server_id = ?`, serverID)
if err != nil {
return fmt.Errorf("failed to clear group memberships: %w", err)
}
// Add new memberships
for _, gid := range groupIDs {
// Verify group belongs to user
_, err := s.GetGroupByID(gid, userID)
if err != nil {
continue
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
gid, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
}
return nil
}
// GetGroupMemberIDs returns server IDs in a group
func (s *Service) GetGroupMemberIDs(groupID, userID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT sgm.server_id FROM server_group_members sgm
JOIN servers s ON s.id = sgm.server_id
WHERE sgm.group_id = ? AND s.user_id = ?`, groupID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query group member IDs: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// --- Global queries (admin/owner) ---
// GetAllServers returns all servers regardless of owner
func (s *Service) GetAllServers() ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers ORDER BY name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all servers: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetAllGroups returns all server groups regardless of owner
func (s *Service) GetAllGroups() ([]models.ServerGroupWithCount, error) {
rows, err := s.db.Query(
`SELECT sg.id, sg.user_id, sg.name, sg.description, sg.created_at, sg.updated_at,
COUNT(sgm.server_id) as server_count
FROM server_groups sg
LEFT JOIN server_group_members sgm ON sg.id = sgm.group_id
GROUP BY sg.id
ORDER BY sg.name ASC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query all server groups: %w", err)
}
defer rows.Close()
var groups []models.ServerGroupWithCount
for rows.Next() {
var g models.ServerGroupWithCount
if err := rows.Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt, &g.ServerCount); err != nil {
return nil, fmt.Errorf("failed to scan server group: %w", err)
}
groups = append(groups, g)
}
return groups, nil
}
// --- Global access functions (admin/owner) ---
// GetByIDGlobal returns a server without user_id check (admin/owner access)
func (s *Service) GetByIDGlobal(serverID int64) (*models.Server, error) {
srv := &models.Server{}
err := s.db.QueryRow(
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
FROM servers WHERE id = ?`, serverID,
).Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server not found: %w", err)
}
return srv, nil
}
// UpdateGlobal modifies a server without user_id check (admin/owner access)
func (s *Service) UpdateGlobal(serverID int64, name, hostname string, port int, username, description string) error {
result, err := s.db.Exec(
`UPDATE servers SET name=?, hostname=?, port=?, username=?, description=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
name, hostname, port, username, description, serverID,
)
if err != nil {
return fmt.Errorf("failed to update server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// DeleteGlobal removes a server without user_id check (admin/owner access)
func (s *Service) DeleteGlobal(serverID int64) error {
result, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, serverID)
if err != nil {
return fmt.Errorf("failed to delete server: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server not found")
}
return nil
}
// GetGroupByIDGlobal returns a server group without user_id check
func (s *Service) GetGroupByIDGlobal(groupID int64) (*models.ServerGroup, error) {
g := &models.ServerGroup{}
err := s.db.QueryRow(
`SELECT id, user_id, name, description, created_at, updated_at
FROM server_groups WHERE id = ?`, groupID,
).Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("server group not found: %w", err)
}
return g, nil
}
// UpdateGroupGlobal modifies a server group without user_id check
func (s *Service) UpdateGroupGlobal(groupID int64, name, description string) error {
result, err := s.db.Exec(
`UPDATE server_groups SET name=?, description=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
name, description, groupID,
)
if err != nil {
return fmt.Errorf("failed to update server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// DeleteGroupGlobal removes a server group without user_id check
func (s *Service) DeleteGroupGlobal(groupID int64) error {
result, err := s.db.Exec(`DELETE FROM server_groups WHERE id = ?`, groupID)
if err != nil {
return fmt.Errorf("failed to delete server group: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("server group not found")
}
return nil
}
// GetGroupMembersGlobal returns all servers in a group without user_id check
func (s *Service) GetGroupMembersGlobal(groupID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
FROM servers s
JOIN server_group_members sgm ON s.id = sgm.server_id
WHERE sgm.group_id = ?
ORDER BY s.name ASC`, groupID,
)
if err != nil {
return nil, fmt.Errorf("failed to query group members: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// GetGroupIDsForServerGlobal returns all group IDs that a server belongs to (without user_id check)
func (s *Service) GetGroupIDsForServerGlobal(serverID int64) ([]int64, error) {
rows, err := s.db.Query(
`SELECT sgm.group_id FROM server_group_members sgm WHERE sgm.server_id = ?`, serverID,
)
if err != nil {
return nil, fmt.Errorf("failed to query server groups: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, nil
}
// SetServerGroupsGlobal replaces all group memberships for a server (without user_id check)
func (s *Service) SetServerGroupsGlobal(serverID int64, groupIDs []int64) error {
_, err := s.db.Exec(`DELETE FROM server_group_members WHERE server_id = ?`, serverID)
if err != nil {
return fmt.Errorf("failed to clear group memberships: %w", err)
}
for _, gid := range groupIDs {
_, err = s.db.Exec(
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
gid, serverID,
)
if err != nil {
return fmt.Errorf("failed to add server to group: %w", err)
}
}
return nil
}
// GetServersByAssignedUser returns hosts that a user has access to via assignments
func (s *Service) GetServersByAssignedUser(userID int64) ([]models.Server, error) {
rows, err := s.db.Query(
`SELECT DISTINCT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
FROM servers s
WHERE s.id IN (
SELECT a.server_id FROM access_assignments a WHERE a.user_id = ? AND a.server_id > 0
UNION
SELECT sgm.server_id FROM access_assignments a
JOIN server_group_members sgm ON a.group_id = sgm.group_id
WHERE a.user_id = ? AND a.group_id > 0
)
ORDER BY s.name ASC`, userID, userID,
)
if err != nil {
return nil, fmt.Errorf("failed to query assigned servers: %w", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var srv models.Server
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan server: %w", err)
}
servers = append(servers, srv)
}
return servers, nil
}
// UpdateAssignmentStatus updates the sync status of an assignment
func (s *Service) UpdateAssignmentStatus(id int64, status, message string) error {
_, err := s.db.Exec(
`UPDATE access_assignments SET status=?, last_sync_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
status, id,
)
return err
}
// --- Access Assignments ---
// CreateAssignment creates a new access assignment
func (s *Service) CreateAssignment(userID, sshKeyID, serverID, groupID int64, systemUser, desiredState string, sudo, createUser bool) (*models.AccessAssignment, error) {
sudoInt := 0
if sudo {
sudoInt = 1
}
createUserInt := 0
if createUser {
createUserInt = 1
}
if desiredState == "" {
desiredState = "present"
}
result, err := s.db.Exec(
`INSERT INTO access_assignments (user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
userID, sshKeyID, serverID, groupID, systemUser, desiredState, sudoInt, createUserInt,
)
if err != nil {
return nil, fmt.Errorf("failed to create assignment: %w", err)
}
id, _ := result.LastInsertId()
return &models.AccessAssignment{
ID: id,
UserID: userID,
SSHKeyID: sshKeyID,
ServerID: serverID,
GroupID: groupID,
SystemUser: systemUser,
DesiredState: desiredState,
Sudo: sudo,
CreateUser: createUser,
Status: "pending",
}, nil
}
// GetAllAssignments returns all access assignments with resolved display names
func (s *Service) GetAllAssignments() ([]models.AccessAssignmentDisplay, error) {
rows, err := s.db.Query(
`SELECT a.id, a.user_id, a.ssh_key_id, a.server_id, a.group_id,
a.system_user, a.desired_state, a.sudo, a.create_user,
a.status, a.initial_password, a.last_sync_at, a.created_at, a.updated_at,
u.username,
COALESCE(k.name, '(deleted)'),
CASE
WHEN a.server_id > 0 THEN COALESCE(s.name, '(deleted)')
WHEN a.group_id > 0 THEN COALESCE(sg.name, '(deleted)')
ELSE '(none)'
END,
CASE
WHEN a.server_id > 0 THEN 'host'
WHEN a.group_id > 0 THEN 'group'
ELSE 'none'
END
FROM access_assignments a
LEFT JOIN users u ON a.user_id = u.id
LEFT JOIN ssh_keys k ON a.ssh_key_id = k.id
LEFT JOIN servers s ON a.server_id = s.id AND a.server_id > 0
LEFT JOIN server_groups sg ON a.group_id = sg.id AND a.group_id > 0
ORDER BY a.created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("failed to query assignments: %w", err)
}
defer rows.Close()
var assignments []models.AccessAssignmentDisplay
for rows.Next() {
var a models.AccessAssignmentDisplay
var sudo, createUser int
if err := rows.Scan(&a.ID, &a.UserID, &a.SSHKeyID, &a.ServerID, &a.GroupID,
&a.SystemUser, &a.DesiredState, &sudo, &createUser,
&a.Status, &a.InitialPassword, &a.LastSyncAt, &a.CreatedAt, &a.UpdatedAt,
&a.Username, &a.KeyName, &a.TargetName, &a.TargetType); err != nil {
return nil, fmt.Errorf("failed to scan assignment: %w", err)
}
a.Sudo = sudo == 1
a.CreateUser = createUser == 1
assignments = append(assignments, a)
}
return assignments, nil
}
// GetAssignmentsByUser returns access assignments for a specific user
func (s *Service) GetAssignmentsByUser(userID int64) ([]models.AccessAssignmentDisplay, error) {
all, err := s.GetAllAssignments()
if err != nil {
return nil, err
}
var filtered []models.AccessAssignmentDisplay
for _, a := range all {
if a.UserID == userID {
filtered = append(filtered, a)
}
}
return filtered, nil
}
// GetAssignmentByID returns a single access assignment
func (s *Service) GetAssignmentByID(id int64) (*models.AccessAssignment, error) {
a := &models.AccessAssignment{}
var sudo, createUser int
err := s.db.QueryRow(
`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state,
sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at
FROM access_assignments WHERE id = ?`, id,
).Scan(&a.ID, &a.UserID, &a.SSHKeyID, &a.ServerID, &a.GroupID, &a.SystemUser, &a.DesiredState,
&sudo, &createUser, &a.InitialPassword, &a.Status, &a.LastSyncAt, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("assignment not found: %w", err)
}
a.Sudo = sudo == 1
a.CreateUser = createUser == 1
return a, nil
}
// UpdateAssignment updates an existing access assignment
func (s *Service) UpdateAssignment(id, userID, sshKeyID, serverID, groupID int64, systemUser, desiredState string, sudo, createUser bool) error {
sudoInt := 0
if sudo {
sudoInt = 1
}
createUserInt := 0
if createUser {
createUserInt = 1
}
result, err := s.db.Exec(
`UPDATE access_assignments SET user_id=?, ssh_key_id=?, server_id=?, group_id=?,
system_user=?, desired_state=?, sudo=?, create_user=?, status='pending', updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
userID, sshKeyID, serverID, groupID, systemUser, desiredState, sudoInt, createUserInt, id,
)
if err != nil {
return fmt.Errorf("failed to update assignment: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}
// DeleteAssignment removes an access assignment
func (s *Service) DeleteAssignment(id int64) error {
result, err := s.db.Exec(`DELETE FROM access_assignments WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("failed to delete assignment: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("assignment not found")
}
return nil
}
// UpdateAssignmentInitialPassword stores the encrypted initial password for an assignment
func (s *Service) UpdateAssignmentInitialPassword(id int64, encryptedPassword string) error {
_, err := s.db.Exec(
`UPDATE access_assignments SET initial_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
encryptedPassword, id,
)
return err
}
+251
View File
@@ -0,0 +1,251 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package sshutil
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/pem"
"fmt"
"strings"
"github.com/cloudflare/circl/sign/ed448"
"golang.org/x/crypto/ssh"
)
// GenerateRSAKey generates an RSA key pair with the given bit size (2048 or 4096)
func GenerateRSAKey(bits int, comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
if bits != 2048 && bits != 4096 {
return nil, nil, "", fmt.Errorf("unsupported RSA key size: %d (use 2048 or 4096)", bits)
}
privKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate RSA key: %w", err)
}
// Encode private key to PEM
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: marshalRSAPrivateKey(privKey),
})
// Generate SSH public key
pub, err := ssh.NewPublicKey(&privKey.PublicKey)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
}
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
fp := fingerprintSHA256(pub)
return privPEM, pubBytes, fp, nil
}
// GenerateEd25519Key generates an Ed25519 key pair
func GenerateEd25519Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate Ed25519 key: %w", err)
}
// Encode private key to PEM using OpenSSH format
privPEM, err := ssh.MarshalPrivateKey(privKey, comment)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to marshal Ed25519 private key: %w", err)
}
privPEMBytes := pem.EncodeToMemory(privPEM)
// Generate SSH public key
pub, err := ssh.NewPublicKey(pubKey)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
}
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
fp := fingerprintSHA256(pub)
return privPEMBytes, pubBytes, fp, nil
}
// ed448PublicKey wraps an Ed448 public key to implement ssh.PublicKey
type ed448PublicKey []byte
func (k ed448PublicKey) Type() string {
return "ssh-ed448"
}
func (k ed448PublicKey) Marshal() []byte {
w := struct {
KeyType string
Key []byte
}{
KeyType: k.Type(),
Key: []byte(k),
}
return ssh.Marshal(&w)
}
func (k ed448PublicKey) Verify(data []byte, sig *ssh.Signature) error {
if sig.Format != k.Type() {
return fmt.Errorf("ssh: signature type %s for key type %s", sig.Format, k.Type())
}
if !ed448.Verify(ed448.PublicKey(k), data, sig.Blob, "") {
return fmt.Errorf("ssh: ed448 signature verification failed")
}
return nil
}
// GenerateEd448Key generates an Ed448 key pair
func GenerateEd448Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
pubKey, privKey, err := ed448.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to generate Ed448 key: %w", err)
}
sshPubKey := ed448PublicKey(pubKey)
privPEM, err := marshalOpenSSHEd448(privKey, pubKey, comment)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to marshal Ed448 private key: %w", err)
}
pubBytes := appendComment(ssh.MarshalAuthorizedKey(sshPubKey), comment)
fp := fingerprintSHA256(sshPubKey)
return privPEM, pubBytes, fp, nil
}
// marshalOpenSSHEd448 encodes an Ed448 key pair in openssh-key-v1 private key format
func marshalOpenSSHEd448(privKey ed448.PrivateKey, pubKey ed448.PublicKey, comment string) ([]byte, error) {
// Public key wire format
pubWire := struct {
KeyType string
PubKey []byte
}{
KeyType: "ssh-ed448",
PubKey: []byte(pubKey),
}
pubWireBytes := ssh.Marshal(&pubWire)
// Random check value for integrity verification
var checkBuf [4]byte
if _, err := rand.Read(checkBuf[:]); err != nil {
return nil, fmt.Errorf("failed to generate random check: %w", err)
}
check := binary.BigEndian.Uint32(checkBuf[:])
// Build the private key blob (seed + public key, following OpenSSH convention)
var keyBlob []byte
if len(privKey) <= ed448.SeedSize {
keyBlob = make([]byte, 0, ed448.SeedSize+len(pubKey))
keyBlob = append(keyBlob, privKey...)
keyBlob = append(keyBlob, pubKey...)
} else {
keyBlob = []byte(privKey)
}
// Private key section (unencrypted)
privSection := struct {
Check1 uint32
Check2 uint32
KeyType string
PubKey []byte
PrivKey []byte
Comment string
}{
Check1: check,
Check2: check,
KeyType: "ssh-ed448",
PubKey: []byte(pubKey),
PrivKey: keyBlob,
Comment: comment,
}
privSectionBytes := ssh.Marshal(&privSection)
// Pad to block size of 8
padLen := (8 - len(privSectionBytes)%8) % 8
for i := 0; i < padLen; i++ {
privSectionBytes = append(privSectionBytes, byte(i+1))
}
// Assemble full openssh-key-v1 format
var buf bytes.Buffer
buf.WriteString("openssh-key-v1\x00")
outer := struct {
CipherName string
KdfName string
KdfOpts string
NumKeys uint32
PubKey []byte
PrivKey []byte
}{
CipherName: "none",
KdfName: "none",
KdfOpts: "",
NumKeys: 1,
PubKey: pubWireBytes,
PrivKey: privSectionBytes,
}
buf.Write(ssh.Marshal(&outer))
return pem.EncodeToMemory(&pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: buf.Bytes(),
}), nil
}
// ParsePublicKey parses an SSH public key and returns its fingerprint
func ParsePublicKey(pubKeyBytes []byte) (fingerprint string, keyType string, err error) {
pub, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
if err != nil {
return "", "", fmt.Errorf("failed to parse public key: %w", err)
}
return fingerprintSHA256(pub), pub.Type(), nil
}
// ParsePrivateKey parses a PEM-encoded private key and extracts the public key
func ParsePrivateKey(privKeyPEM []byte) (publicKey []byte, fingerprint string, keyType string, err error) {
signer, err := ssh.ParsePrivateKey(privKeyPEM)
if err != nil {
return nil, "", "", fmt.Errorf("failed to parse private key: %w", err)
}
pub := signer.PublicKey()
pubBytes := ssh.MarshalAuthorizedKey(pub)
fp := fingerprintSHA256(pub)
return pubBytes, fp, pub.Type(), nil
}
// appendComment appends a comment to an SSH authorized key line
func appendComment(pubBytes []byte, comment string) []byte {
if comment == "" {
return pubBytes
}
// MarshalAuthorizedKey returns "type base64\n", insert comment before newline
line := strings.TrimRight(string(pubBytes), "\n")
return []byte(line + " " + comment + "\n")
}
// fingerprintSHA256 returns the SHA256 fingerprint of an SSH public key
func fingerprintSHA256(pub ssh.PublicKey) string {
hash := sha256.Sum256(pub.Marshal())
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
}
// marshalRSAPrivateKey marshals an RSA private key to PKCS#1 DER bytes
func marshalRSAPrivateKey(key *rsa.PrivateKey) []byte {
return x509.MarshalPKCS1PrivateKey(key)
}
+243
View File
@@ -0,0 +1,243 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package sshutil
import (
"strings"
"testing"
)
func TestGenerateEd25519Key(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateEd25519Key("test@keywarden")
if err != nil {
t.Fatalf("GenerateEd25519Key failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if !strings.Contains(string(pubKey), "ssh-ed25519") {
t.Fatal("Public key should contain ssh-ed25519")
}
if !strings.Contains(string(pubKey), "test@keywarden") {
t.Fatal("Public key should contain the comment")
}
if !strings.Contains(string(privPEM), "PRIVATE KEY") {
t.Fatal("Private key PEM should contain PRIVATE KEY header")
}
}
func TestGenerateRSAKey2048(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateRSAKey(2048, "rsa-test")
if err != nil {
t.Fatalf("GenerateRSAKey(2048) failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if !strings.Contains(string(pubKey), "ssh-rsa") {
t.Fatal("Public key should contain ssh-rsa")
}
if !strings.Contains(string(pubKey), "rsa-test") {
t.Fatal("Public key should contain the comment")
}
}
func TestGenerateRSAKey4096(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateRSAKey(4096, "")
if err != nil {
t.Fatalf("GenerateRSAKey(4096) failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
}
func TestGenerateRSAKeyInvalidBits(t *testing.T) {
_, _, _, err := GenerateRSAKey(1024, "")
if err == nil {
t.Fatal("GenerateRSAKey(1024) should fail for unsupported key size")
}
_, _, _, err = GenerateRSAKey(3072, "")
if err == nil {
t.Fatal("GenerateRSAKey(3072) should fail for unsupported key size")
}
}
func TestParsePrivateKeyEd25519(t *testing.T) {
privPEM, expectedPub, _, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key failed: %v", err)
}
pubKey, fingerprint, keyType, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatalf("ParsePrivateKey failed: %v", err)
}
if keyType != "ssh-ed25519" {
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if strings.TrimSpace(string(pubKey)) != strings.TrimSpace(string(expectedPub)) {
t.Fatal("Parsed public key does not match generated public key")
}
}
func TestParsePrivateKeyRSA(t *testing.T) {
privPEM, _, _, err := GenerateRSAKey(2048, "")
if err != nil {
t.Fatalf("GenerateRSAKey failed: %v", err)
}
_, fingerprint, keyType, err := ParsePrivateKey(privPEM)
if err != nil {
t.Fatalf("ParsePrivateKey failed: %v", err)
}
if keyType != "ssh-rsa" {
t.Fatalf("Expected key type ssh-rsa, got %q", keyType)
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
}
func TestParsePublicKey(t *testing.T) {
_, pubKey, expectedFP, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key failed: %v", err)
}
fingerprint, keyType, err := ParsePublicKey(pubKey)
if err != nil {
t.Fatalf("ParsePublicKey failed: %v", err)
}
if keyType != "ssh-ed25519" {
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
}
if fingerprint != expectedFP {
t.Fatalf("Fingerprint mismatch: got %q, want %q", fingerprint, expectedFP)
}
}
func TestGenerateEd25519KeyUniqueness(t *testing.T) {
_, pub1, fp1, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key 1 failed: %v", err)
}
_, pub2, fp2, err := GenerateEd25519Key("")
if err != nil {
t.Fatalf("GenerateEd25519Key 2 failed: %v", err)
}
if string(pub1) == string(pub2) {
t.Fatal("Two generated keys should have different public keys")
}
if fp1 == fp2 {
t.Fatal("Two generated keys should have different fingerprints")
}
}
func TestGenerateEd448Key(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateEd448Key("test@keywarden")
if err != nil {
t.Fatalf("GenerateEd448Key failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
if !strings.Contains(string(pubKey), "ssh-ed448") {
t.Fatal("Public key should contain ssh-ed448")
}
if !strings.Contains(string(pubKey), "test@keywarden") {
t.Fatal("Public key should contain the comment")
}
if !strings.Contains(string(privPEM), "OPENSSH PRIVATE KEY") {
t.Fatal("Private key PEM should contain OPENSSH PRIVATE KEY header")
}
}
func TestGenerateEd448KeyUniqueness(t *testing.T) {
_, pub1, fp1, err := GenerateEd448Key("")
if err != nil {
t.Fatalf("GenerateEd448Key 1 failed: %v", err)
}
_, pub2, fp2, err := GenerateEd448Key("")
if err != nil {
t.Fatalf("GenerateEd448Key 2 failed: %v", err)
}
if string(pub1) == string(pub2) {
t.Fatal("Two generated Ed448 keys should have different public keys")
}
if fp1 == fp2 {
t.Fatal("Two generated Ed448 keys should have different fingerprints")
}
}
func TestGenerateEd448KeyNoComment(t *testing.T) {
privPEM, pubKey, fingerprint, err := GenerateEd448Key("")
if err != nil {
t.Fatalf("GenerateEd448Key failed: %v", err)
}
if len(privPEM) == 0 {
t.Fatal("Private key PEM is empty")
}
if len(pubKey) == 0 {
t.Fatal("Public key is empty")
}
if !strings.HasPrefix(fingerprint, "SHA256:") {
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
}
}
func TestParsePrivateKeyInvalid(t *testing.T) {
_, _, _, err := ParsePrivateKey([]byte("not a valid PEM"))
if err == nil {
t.Fatal("ParsePrivateKey should fail for invalid PEM")
}
}
func TestParsePublicKeyInvalid(t *testing.T) {
_, _, err := ParsePublicKey([]byte("not a valid public key"))
if err == nil {
t.Fatal("ParsePublicKey should fail for invalid key")
}
}
+13
View File
@@ -0,0 +1,13 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import "embed"
//go:embed templates/* templates/layout/*
var TemplateFS embed.FS
//go:embed static/css/* static/css/fonts/* static/js/* static/favicon.svg
var StaticFS embed.FS
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+13
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<circle cx="12" cy="12" r="12" fill="#206bc4"/>
<g transform="translate(3.5 3.5) scale(0.71)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="15" r="4"/>
<line x1="10.85" y1="12.15" x2="19" y2="4"/>
<line x1="18" y1="5" x2="20" y2="7"/>
<line x1="15" y1="8" x2="17" y2="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 456 B

+15
View File
File diff suppressed because one or more lines are too long
+432
View File
@@ -0,0 +1,432 @@
{{define "content"}}
<div class="row row-deck row-cards">
<!-- Application Settings -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-settings"></i> Application Settings</h3>
</div>
<div class="card-body">
<form action="/admin/settings" method="post">
<input type="hidden" name="form_type" value="app_settings">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Application Name</label>
<input type="text" name="app_name" class="form-control" value="{{index .Settings "app_name"}}" placeholder="Keywarden">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Default Key Type</label>
<select name="default_key_type" class="form-select">
<option value="ed25519" {{if eq (index .Settings "default_key_type") "ed25519"}}selected{{end}}>Ed25519</option>
<option value="rsa" {{if eq (index .Settings "default_key_type") "rsa"}}selected{{end}}>RSA</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Default RSA Key Bits</label>
<select name="default_key_bits" class="form-select">
<option value="4096" {{if eq (index .Settings "default_key_bits") "4096"}}selected{{end}}>4096</option>
<option value="2048" {{if eq (index .Settings "default_key_bits") "2048"}}selected{{end}}>2048</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Session Timeout (minutes)</label>
<input type="number" name="session_timeout" class="form-control" value="{{index .Settings "session_timeout"}}" placeholder="60" min="5" max="1440">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Settings
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Security Settings -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-shield-lock"></i> Security Settings</h3>
</div>
<div class="card-body">
<form action="/admin/settings" method="post">
<input type="hidden" name="form_type" value="security_settings">
<!-- Password Policy -->
<h4 class="mb-3"><i class="ti ti-lock"></i> Password Policy</h4>
<div class="row mb-3">
<div class="col-md-4 mb-3">
<label class="form-label">Minimum Password Length</label>
<input type="number" name="pw_min_length" class="form-control"
value="{{if index .Settings "pw_min_length"}}{{index .Settings "pw_min_length"}}{{else}}8{{end}}"
min="4" max="128" placeholder="8">
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="pw_require_upper" value="true"
{{if or (eq (index .Settings "pw_require_upper") "true") (eq (index .Settings "pw_require_upper") "")}}checked{{end}}>
<span class="form-check-label">Require uppercase letter (A-Z)</span>
</label>
</div>
<div class="col-md-6">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="pw_require_lower" value="true"
{{if or (eq (index .Settings "pw_require_lower") "true") (eq (index .Settings "pw_require_lower") "")}}checked{{end}}>
<span class="form-check-label">Require lowercase letter (a-z)</span>
</label>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="pw_require_digit" value="true"
{{if or (eq (index .Settings "pw_require_digit") "true") (eq (index .Settings "pw_require_digit") "")}}checked{{end}}>
<span class="form-check-label">Require digit (0-9)</span>
</label>
</div>
<div class="col-md-6">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="pw_require_special" value="true"
{{if eq (index .Settings "pw_require_special") "true"}}checked{{end}}>
<span class="form-check-label">Require special character (!@#$...)</span>
</label>
</div>
</div>
<hr class="my-4">
<!-- MFA Enforcement -->
<h4 class="mb-3"><i class="ti ti-shield-check"></i> MFA Enforcement</h4>
<div class="row mb-3">
<div class="col-md-8">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="mfa_required" value="true"
{{if eq (index .Settings "mfa_required") "true"}}checked{{end}}>
<span class="form-check-label">Require MFA for all users</span>
<span class="form-check-description">When enabled, all users must set up two-factor authentication before using the application. Users without MFA will be redirected to the MFA setup page.</span>
</label>
</div>
</div>
<hr class="my-4">
<!-- Account Lockout -->
<h4 class="mb-3"><i class="ti ti-lock-access"></i> Account Lockout</h4>
<div class="row mb-3">
<div class="col-md-4 mb-3">
<label class="form-label">Failed Attempts Before Lockout</label>
<input type="number" name="lockout_attempts" class="form-control"
value="{{if index .Settings "lockout_attempts"}}{{index .Settings "lockout_attempts"}}{{else}}5{{end}}"
min="0" max="100" placeholder="5">
<small class="form-hint">Set to 0 to disable account lockout.</small>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Lockout Duration (minutes)</label>
<input type="number" name="lockout_duration" class="form-control"
value="{{if index .Settings "lockout_duration"}}{{index .Settings "lockout_duration"}}{{else}}15{{end}}"
min="1" max="1440" placeholder="15">
<small class="form-hint">How long to lock the account after too many failed attempts.</small>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Security Settings
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Email / SMTP Configuration -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-mail"></i> Email / SMTP</h3>
</div>
<div class="card-body">
{{if .EmailEnabled}}
<div class="alert alert-success">
<div class="d-flex">
<div><i class="ti ti-check icon alert-icon"></i></div>
<div>
<h4 class="alert-title">SMTP is configured</h4>
<div class="text-secondary">Email notifications are available. SMTP settings are managed via environment variables.</div>
</div>
</div>
</div>
<form action="/admin/settings/email/test" method="post">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Send Test Email</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-mail"></i></span>
<input type="email" name="test_email" class="form-control" placeholder="recipient@example.com" required>
</div>
</div>
<div class="col-auto mb-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary">
<i class="ti ti-send"></i> Send Test
</button>
</div>
</div>
<small class="form-hint mt-n2 d-block mb-3">Send a test email to verify your SMTP configuration.</small>
</form>
{{else}}
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">SMTP is not configured</h4>
<div class="text-secondary">
Set the <code>KEYWARDEN_SMTP_HOST</code> environment variable to enable email notifications.
See the <a href="https://git.techniverse.net/scriptos/keywarden/src/branch/master/docs/email.md" target="_blank">Email documentation</a> for details.
</div>
</div>
</div>
</div>
{{end}}
</div>
</div>
</div>
<!-- System Master Key -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-key"></i> System Master Key</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">About the System Master Key</h4>
<div class="text-secondary">
The system master key is used by Keywarden to authenticate against remote servers for
key deployments and assignment syncs. You must add this public key to the
<code>~/.ssh/authorized_keys</code> file of the admin user on each target server.
</div>
</div>
</div>
</div>
{{if .MasterKeyPublic}}
<input type="hidden" id="masterKeyValue" value="{{.MasterKeyPublic}}">
<div class="mb-3">
<label class="form-label">Public Key</label>
<div class="input-group">
<input type="password" class="form-control" id="masterKeyDisplay" value="{{.MasterKeyPublic}}" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="toggleMasterKey()" title="Show/Hide">
<i class="ti ti-eye" id="masterKeyEyeIcon"></i>
</button>
<button class="btn btn-outline-primary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('masterKeyValue').value); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy">
<i class="ti ti-copy"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Fingerprint</label>
<code>{{.MasterKeyFingerprint}}</code>
</div>
<script>
function toggleMasterKey() {
var input = document.getElementById('masterKeyDisplay');
var icon = document.getElementById('masterKeyEyeIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'ti ti-eye-off';
} else {
input.type = 'password';
icon.className = 'ti ti-eye';
}
}
</script>
{{else}}
<div class="alert alert-danger">
<i class="ti ti-alert-triangle"></i> System master key not found. Please restart Keywarden to generate it.
</div>
{{end}}
<hr>
<h4 class="mb-3"><i class="ti ti-refresh"></i> Regenerate Master Key</h4>
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Warning</h4>
<div class="text-secondary">
Regenerating the master key will invalidate all existing server connections.
You must re-deploy the new public key to all target servers. This action cannot be undone.
</div>
</div>
</div>
</div>
<form action="/admin/masterkey/regenerate" method="post" onsubmit="return confirm('Are you absolutely sure you want to regenerate the system master key? All existing server connections will break until the new key is deployed.');">
<div class="row align-items-end">
<div class="col-md-6 mb-3">
<label class="form-label">Confirm your password</label>
<input type="password" name="confirm_password" class="form-control" placeholder="Enter your password" required>
</div>
<div class="col-auto mb-3">
<button type="submit" class="btn btn-danger">
<i class="ti ti-refresh"></i> Regenerate Master Key
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Backup & Restore -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-database-export"></i> Backup & Restore</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">About Backups</h4>
<div class="text-secondary">
Backups contain all system data including users, SSH keys, servers, groups, assignments,
cron jobs, settings, and audit logs. The backup file is encrypted with AES-256-GCM using
the password you provide. Keep the password safe — without it, the backup cannot be restored.
</div>
</div>
</div>
</div>
<!-- Export Backup -->
<h4 class="mb-3"><i class="ti ti-download"></i> Export Backup</h4>
<form action="/admin/backup/export" method="post" id="backupExportForm">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Backup Password</label>
<input type="password" name="backup_password" id="backup_password" class="form-control" placeholder="Enter a secure password" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" name="backup_password_confirm" id="backup_password_confirm" class="form-control" placeholder="Confirm password" required>
</div>
<div class="col-auto mb-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary" onclick="return validateBackupForm()">
<i class="ti ti-download"></i> Export Backup
</button>
</div>
</div>
<small class="form-hint mt-n2 d-block mb-3">Password must meet the configured password policy.</small>
</form>
<hr class="my-4">
<!-- Import Backup -->
<h4 class="mb-3"><i class="ti ti-upload"></i> Restore Backup</h4>
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Warning</h4>
<div class="text-secondary">
Restoring a backup will <strong>replace all current data</strong> with the data from the backup file.
This action cannot be undone. Make sure to export a current backup first if needed.
</div>
</div>
</div>
</div>
<form action="/admin/backup/import" method="post" enctype="multipart/form-data" onsubmit="return confirm('Are you absolutely sure you want to restore this backup? ALL current data will be replaced. This action cannot be undone!');">
<div class="row">
<div class="col-md-5 mb-3">
<label class="form-label">Backup File (.kwbak)</label>
<input type="file" name="backup_file" class="form-control" accept=".kwbak" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Backup Password</label>
<input type="password" name="restore_password" class="form-control" placeholder="Enter the backup password" required>
</div>
<div class="col-auto mb-3 d-flex align-items-end">
<button type="submit" class="btn btn-danger">
<i class="ti ti-upload"></i> Restore Backup
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
function validateBackupForm() {
var pw = document.getElementById('backup_password').value;
var pwConfirm = document.getElementById('backup_password_confirm').value;
if (pw !== pwConfirm) {
alert('Passwords do not match.');
return false;
}
if (pw.length < 4) {
alert('Password is too short.');
return false;
}
return true;
}
// Track unsaved changes on forms (except backup/masterkey forms)
(function() {
var settingsForms = document.querySelectorAll('form[action="/admin/settings"]');
settingsForms.forEach(function(form) {
var inputs = form.querySelectorAll('input, select, textarea');
var saveBtn = form.querySelector('button[type="submit"]');
var banner = null;
function markDirty() {
form.dataset.dirty = 'true';
// Highlight save button
if (saveBtn) {
saveBtn.classList.remove('btn-primary');
saveBtn.classList.add('btn-warning');
saveBtn.innerHTML = '<i class="ti ti-alert-triangle"></i> Unsaved changes Save now!';
}
// Show banner if not already visible
if (!banner) {
banner = document.createElement('div');
banner.className = 'alert alert-warning alert-dismissible mt-2 mb-0 py-2';
banner.setAttribute('role', 'alert');
banner.innerHTML = '<i class="ti ti-alert-triangle"></i> You have unsaved changes. Click the save button to apply them.';
form.querySelector('.form-footer').insertAdjacentElement('beforebegin', banner);
}
}
inputs.forEach(function(input) {
if (input.type === 'hidden') return;
input.addEventListener('change', markDirty);
input.addEventListener('input', markDirty);
});
form.addEventListener('submit', function() {
form.dataset.dirty = '';
window._adminSettingsSaving = true;
});
});
// Warn before leaving with unsaved changes
window.addEventListener('beforeunload', function(e) {
if (window._adminSettingsSaving) return;
var dirty = document.querySelector('form[data-dirty="true"]');
if (dirty) {
e.preventDefault();
e.returnValue = '';
}
});
})();
</script>
{{end}}
+259
View File
@@ -0,0 +1,259 @@
{{define "content"}}
<div class="row row-deck row-cards">
{{/* Show assigned hosts for User role */}}
{{if and (eq .User.Role "user") .Servers}}
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-server"></i> My Assigned Hosts</h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Port</th>
</tr>
</thead>
<tbody>
{{range .Servers}}
<tr>
<td>
<div class="d-flex align-items-center">
<i class="ti ti-server me-2 text-primary"></i>
<strong>{{.Name}}</strong>
</div>
</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-shield-lock"></i> {{if eq .User.Role "user"}}My Access Assignments{{else}}Access Assignments{{end}}</h3>
{{if or (eq .User.Role "admin") (eq .User.Role "owner")}}
<div class="card-actions">
<a href="/assignments/add" class="btn btn-primary">
<i class="ti ti-plus"></i> Create Assignment
</a>
</div>
{{end}}
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
<th>ID</th>
<th>User</th>
{{end}}
<th>SSH Key</th>
<th>Target</th>
<th>System User</th>
<th>State</th>
<th>Options</th>
<th>Password</th>
<th>Status</th>
<th>Created</th>
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
<th class="w-1">Actions</th>
{{end}}
</tr>
</thead>
<tbody>
{{range .Assignments}}
<tr>
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
<td>{{.ID}}</td>
<td><i class="ti ti-user"></i> {{.Username}}</td>
{{end}}
<td><i class="ti ti-key"></i> {{.KeyName}}</td>
<td>
{{if eq .TargetType "host"}}
<span class="badge bg-blue-lt"><i class="ti ti-server"></i> {{.TargetName}}</span>
{{else if eq .TargetType "group"}}
<span class="badge bg-purple-lt"><i class="ti ti-folders"></i> {{.TargetName}}</span>
{{else}}
<span class="text-secondary"></span>
{{end}}
</td>
<td><code>{{.SystemUser}}</code></td>
<td>
{{if eq .DesiredState "present"}}
<span class="badge bg-green-lt">Present</span>
{{else}}
<span class="badge bg-red-lt">Absent</span>
{{end}}
</td>
<td>
{{if .Sudo}}<span class="badge bg-orange-lt" title="Sudo enabled"><i class="ti ti-shield-check"></i> Sudo</span> {{end}}
{{if .CreateUser}}<span class="badge bg-cyan-lt" title="Create system user"><i class="ti ti-user-plus"></i> Create</span>{{end}}
{{if and (not .Sudo) (not .CreateUser)}}<span class="text-secondary"></span>{{end}}
</td>
<td>
{{if .InitialPassword}}
<div class="d-flex align-items-center">
<code class="initial-pw-hidden" id="pw-hidden-{{.ID}}">••••••••••</code>
<code class="initial-pw-visible d-none" id="pw-visible-{{.ID}}">{{.InitialPassword}}</code>
<button class="btn btn-sm btn-icon btn-ghost-secondary ms-1" type="button" onclick="togglePassword({{.ID}})" title="Show/Hide password">
<i class="ti ti-eye" id="pw-icon-{{.ID}}"></i>
</button>
<button class="btn btn-sm btn-icon btn-ghost-secondary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('pw-visible-{{.ID}}').textContent); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy password">
<i class="ti ti-copy"></i>
</button>
</div>
{{else}}
<span class="text-secondary"></span>
{{end}}
</td>
<td>
{{if eq .Status "synced"}}
<span class="badge bg-green-lt"><i class="ti ti-check"></i> Synced</span>
{{else if eq .Status "failed"}}
<span class="badge bg-red-lt"><i class="ti ti-x"></i> Failed</span>
{{else}}
<span class="badge bg-yellow-lt"><i class="ti ti-clock"></i> Pending</span>
{{end}}
</td>
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
<td>
<div class="btn-list flex-nowrap">
<a href="/assignments/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
<i class="ti ti-edit"></i>
</a>
<form method="POST" action="/assignments/{{.ID}}/sync" class="d-inline" onsubmit="return confirm('Sync this assignment now using the system master key?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-success" title="Sync / Deploy (uses system master key)">
<i class="ti ti-refresh"></i>
</button>
</form>
<button type="button" class="btn btn-sm btn-icon btn-outline-danger" title="Delete"
onclick="openDeleteModal({{.ID}}, '{{.SystemUser}}', '{{.TargetName}}', {{.CreateUser}})">
<i class="ti ti-trash"></i>
</button>
</div>
</td>
{{end}}
</tr>
{{else}}
<tr>
<td colspan="{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}11{{else}}8{{end}}" class="text-center text-secondary">
{{if eq $.User.Role "user"}}No access assignments found for your account.{{else}}No access assignments found. <a href="/assignments/add">Create the first one</a>.{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function togglePassword(id) {
var hidden = document.getElementById('pw-hidden-' + id);
var visible = document.getElementById('pw-visible-' + id);
var icon = document.getElementById('pw-icon-' + id);
if (visible.classList.contains('d-none')) {
visible.classList.remove('d-none');
hidden.classList.add('d-none');
icon.className = 'ti ti-eye-off';
} else {
visible.classList.add('d-none');
hidden.classList.remove('d-none');
icon.className = 'ti ti-eye';
}
}
function openDeleteModal(assignID, systemUser, targetName, wasCreated) {
document.getElementById('deleteAssignForm').action = '/assignments/' + assignID + '/delete';
document.getElementById('deleteModalSystemUser').textContent = systemUser;
document.getElementById('deleteModalTarget').textContent = targetName;
var deleteUserSection = document.getElementById('deleteUserSection');
var deleteUserCheckbox = document.getElementById('deleteUserCheckbox');
// Only show user deletion option if the user was created by the assignment and is not root
if (wasCreated && systemUser !== 'root') {
deleteUserSection.classList.remove('d-none');
deleteUserCheckbox.checked = false;
} else {
deleteUserSection.classList.add('d-none');
deleteUserCheckbox.checked = false;
}
// Update the warning text based on checkbox state
updateDeleteWarning();
var modal = new bootstrap.Modal(document.getElementById('deleteAssignModal'));
modal.show();
}
function updateDeleteWarning() {
var checkbox = document.getElementById('deleteUserCheckbox');
var warningText = document.getElementById('deleteWarningText');
var warningBox = document.getElementById('deleteWarningBox');
if (checkbox.checked) {
warningText.innerHTML = '<strong>Warning:</strong> The system user will be completely removed from the server, including their home directory, sudo rights, and SSH keys. This action cannot be undone!';
warningBox.className = 'alert alert-danger';
} else {
warningText.innerHTML = 'The SSH key will be removed from the server. The system user will remain on the server.';
warningBox.className = 'alert alert-info';
}
}
// Move delete modal to body so it is not clipped by overflow containers
document.addEventListener('DOMContentLoaded', function() {
var modal = document.getElementById('deleteAssignModal');
if (modal) {
document.body.appendChild(modal);
}
});
</script>
<!-- Delete Assignment Modal -->
<div class="modal modal-blur fade" id="deleteAssignModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<form method="POST" id="deleteAssignForm" action="">
<div class="modal-header">
<h5 class="modal-title"><i class="ti ti-alert-triangle text-danger"></i> Delete Access Assignment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>You are about to delete the assignment for system user <strong><code id="deleteModalSystemUser"></code></strong> on target <strong id="deleteModalTarget"></strong>.</p>
<div id="deleteWarningBox" class="alert alert-info">
<span id="deleteWarningText">The SSH key will be removed from the server. The system user will remain on the server.</span>
</div>
<div id="deleteUserSection" class="d-none">
<hr>
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="delete_user" id="deleteUserCheckbox" value="on" onchange="updateDeleteWarning()">
<span class="form-check-label">
<strong>Also delete the Linux system user</strong><br>
<small class="text-secondary">Removes the user account, home directory, sudo rights, and all SSH keys from the server.</small>
</span>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="ti ti-trash"></i> Delete Assignment
</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+140
View File
@@ -0,0 +1,140 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-plus"></i> Create Access Assignment</h3>
</div>
<div class="card-body">
<form action="/assignments/add" method="POST" id="assignment-form">
<!-- User Selection -->
<div class="mb-3">
<label class="form-label required">User</label>
<select name="user_id" id="user-select" class="form-select" required>
<option value=""> Select user </option>
{{range .AssignAllUsers}}
<option value="{{.ID}}">{{.Username}} ({{.Email}})</option>
{{end}}
</select>
</div>
<!-- SSH Key Selection -->
<div class="mb-3">
<label class="form-label required">SSH Key</label>
<select name="ssh_key_id" id="key-select" class="form-select" required>
<option value=""> Select user first </option>
</select>
<small class="form-hint">Only keys of the selected user are shown.</small>
</div>
<!-- Target Type -->
<div class="mb-3">
<label class="form-label required">Target Type</label>
<div class="form-selectgroup">
<label class="form-selectgroup-item">
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" checked onchange="toggleTarget()">
<span class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</span>
</label>
<label class="form-selectgroup-item">
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" onchange="toggleTarget()">
<span class="form-selectgroup-label"><i class="ti ti-folders"></i> Host Group</span>
</label>
</div>
</div>
<!-- Host Selection -->
<div class="mb-3" id="host-section">
<label class="form-label required">Host</label>
<select name="server_id" id="server-select" class="form-select">
<option value=""> Select host </option>
{{range .AssignAllHosts}}
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
{{end}}
</select>
</div>
<!-- Group Selection -->
<div class="mb-3 d-none" id="group-section">
<label class="form-label required">Host Group</label>
<select name="group_id" id="group-select" class="form-select">
<option value=""> Select group </option>
{{range .AssignAllGroups}}
<option value="{{.ID}}">{{.Name}} ({{.ServerCount}} hosts)</option>
{{end}}
</select>
</div>
<!-- System User -->
<div class="mb-3">
<label class="form-label required">System User</label>
<input type="text" name="system_user" class="form-control" placeholder="e.g. root, deploy, ubuntu" required>
<small class="form-hint">The user account on the target host under which the key will be deployed.</small>
</div>
<!-- Desired State -->
<div class="mb-3">
<label class="form-label required">Desired State</label>
<select name="desired_state" class="form-select">
<option value="present" selected>Present Key should be deployed</option>
<option value="absent">Absent Key should be removed</option>
</select>
</div>
<!-- Options -->
<div class="mb-3">
<label class="form-label">Options</label>
<div>
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="sudo">
<span class="form-check-label">Grant sudo rights to the system user</span>
</label>
</div>
<div class="mt-2">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="create_user">
<span class="form-check-label">Create system user on target host if it doesn't exist</span>
</label>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-shield-lock"></i> Create Assignment
</button>
<a href="/assignments" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
// Key data grouped by user ID
var keysByUser = {};
{{range .AssignAllKeys}}
if (!keysByUser[{{.UserID}}]) keysByUser[{{.UserID}}] = [];
keysByUser[{{.UserID}}].push({id: {{.ID}}, name: "{{.Name}}", type: "{{.KeyType}}"});
{{end}}
document.getElementById('user-select').addEventListener('change', function() {
var uid = this.value;
var keySelect = document.getElementById('key-select');
keySelect.innerHTML = '<option value=""> Select key </option>';
if (uid && keysByUser[uid]) {
keysByUser[uid].forEach(function(k) {
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = k.name + ' (' + k.type + ')';
keySelect.appendChild(opt);
});
}
});
function toggleTarget() {
var isHost = document.querySelector('input[name="target_type"][value="host"]').checked;
document.getElementById('host-section').classList.toggle('d-none', !isHost);
document.getElementById('group-section').classList.toggle('d-none', isHost);
}
</script>
{{end}}
+154
View File
@@ -0,0 +1,154 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-edit"></i> Edit Access Assignment #{{.Assignment.ID}}</h3>
</div>
<div class="card-body">
<form action="/assignments/{{.Assignment.ID}}/edit" method="POST" id="assignment-form">
<!-- User Selection -->
<div class="mb-3">
<label class="form-label required">User</label>
<select name="user_id" id="user-select" class="form-select" required>
<option value=""> Select user </option>
{{range .AssignAllUsers}}
<option value="{{.ID}}" {{if eq .ID $.Assignment.UserID}}selected{{end}}>{{.Username}} ({{.Email}})</option>
{{end}}
</select>
</div>
<!-- SSH Key Selection -->
<div class="mb-3">
<label class="form-label required">SSH Key</label>
<select name="ssh_key_id" id="key-select" class="form-select" required>
<option value=""> Select key </option>
</select>
<small class="form-hint">Only keys of the selected user are shown.</small>
</div>
<!-- Target Type -->
<div class="mb-3">
<label class="form-label required">Target Type</label>
<div class="form-selectgroup">
<label class="form-selectgroup-item">
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" {{if gt .Assignment.ServerID 0}}checked{{end}}{{if and (eq .Assignment.ServerID 0) (eq .Assignment.GroupID 0)}}checked{{end}} onchange="toggleTarget()">
<span class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</span>
</label>
<label class="form-selectgroup-item">
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" {{if gt .Assignment.GroupID 0}}checked{{end}} onchange="toggleTarget()">
<span class="form-selectgroup-label"><i class="ti ti-folders"></i> Host Group</span>
</label>
</div>
</div>
<!-- Host Selection -->
<div class="mb-3{{if gt .Assignment.GroupID 0}} d-none{{end}}" id="host-section">
<label class="form-label required">Host</label>
<select name="server_id" id="server-select" class="form-select">
<option value=""> Select host </option>
{{range .AssignAllHosts}}
<option value="{{.ID}}" {{if eq .ID $.Assignment.ServerID}}selected{{end}}>{{.Name}} ({{.Hostname}}:{{.Port}})</option>
{{end}}
</select>
</div>
<!-- Group Selection -->
<div class="mb-3{{if not (gt .Assignment.GroupID 0)}} d-none{{end}}" id="group-section">
<label class="form-label required">Host Group</label>
<select name="group_id" id="group-select" class="form-select">
<option value=""> Select group </option>
{{range .AssignAllGroups}}
<option value="{{.ID}}" {{if eq .ID $.Assignment.GroupID}}selected{{end}}>{{.Name}} ({{.ServerCount}} hosts)</option>
{{end}}
</select>
</div>
<!-- System User -->
<div class="mb-3">
<label class="form-label required">System User</label>
<input type="text" name="system_user" class="form-control" value="{{.Assignment.SystemUser}}" required>
<small class="form-hint">The user account on the target host under which the key will be deployed.</small>
</div>
<!-- Desired State -->
<div class="mb-3">
<label class="form-label required">Desired State</label>
<select name="desired_state" class="form-select">
<option value="present" {{if eq .Assignment.DesiredState "present"}}selected{{end}}>Present Key should be deployed</option>
<option value="absent" {{if eq .Assignment.DesiredState "absent"}}selected{{end}}>Absent Key should be removed</option>
</select>
</div>
<!-- Options -->
<div class="mb-3">
<label class="form-label">Options</label>
<div>
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="sudo" {{if .Assignment.Sudo}}checked{{end}}>
<span class="form-check-label">Grant sudo rights to the system user</span>
</label>
</div>
<div class="mt-2">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="create_user" {{if .Assignment.CreateUser}}checked{{end}}>
<span class="form-check-label">Create system user on target host if it doesn't exist</span>
</label>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Changes
</button>
<a href="/assignments" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
// Key data grouped by user ID
var keysByUser = {};
{{range .AssignAllKeys}}
if (!keysByUser[{{.UserID}}]) keysByUser[{{.UserID}}] = [];
keysByUser[{{.UserID}}].push({id: {{.ID}}, name: "{{.Name}}", type: "{{.KeyType}}"});
{{end}}
var preselectedKeyID = {{.Assignment.SSHKeyID}};
function populateKeys(uid, selectedKeyID) {
var keySelect = document.getElementById('key-select');
keySelect.innerHTML = '<option value=""> Select key </option>';
if (uid && keysByUser[uid]) {
keysByUser[uid].forEach(function(k) {
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = k.name + ' (' + k.type + ')';
if (k.id == selectedKeyID) opt.selected = true;
keySelect.appendChild(opt);
});
}
}
document.getElementById('user-select').addEventListener('change', function() {
populateKeys(this.value, 0);
});
// Initialize on page load with pre-selected values
(function() {
var userSelect = document.getElementById('user-select');
if (userSelect.value) {
populateKeys(userSelect.value, preselectedKeyID);
}
})();
function toggleTarget() {
var isHost = document.querySelector('input[name="target_type"][value="host"]').checked;
document.getElementById('host-section').classList.toggle('d-none', !isHost);
document.getElementById('group-section').classList.toggle('d-none', isHost);
}
</script>
{{end}}
+167
View File
@@ -0,0 +1,167 @@
{{define "content"}}
<div class="row row-deck row-cards">
<!-- Filter / Info -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-list-details"></i> Audit Log</h3>
<div class="card-actions">
{{if .AuditIsAdmin}}
<div class="btn-group">
<a href="/audit" class="btn btn-sm {{if not .AuditFilterUser}}btn-primary{{else}}btn-outline-primary{{end}}">
<i class="ti ti-users"></i> All Users
</a>
<a href="/audit?filter=mine" class="btn btn-sm {{if .AuditFilterUser}}btn-primary{{else}}btn-outline-primary{{end}}">
<i class="ti ti-user"></i> My Actions
</a>
</div>
{{end}}
<span class="badge bg-blue-lt ms-2">{{.AuditTotal}} entries</span>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table table-striped">
<thead>
<tr>
<th style="width: 170px;">Timestamp</th>
<th style="width: 130px;">User</th>
<th style="width: 180px;">Action</th>
<th>Details</th>
<th style="width: 130px;">IP Address</th>
</tr>
</thead>
<tbody>
{{range .AuditEntries}}
<tr>
<td class="text-secondary">
<i class="ti ti-clock"></i> {{.CreatedAt.Format "2006-01-02 15:04:05"}}
</td>
<td>
<span class="badge bg-cyan-lt"><i class="ti ti-user"></i> {{.Username}}</span>
</td>
<td>
{{if eq .Action "login_success"}}
<span class="badge bg-green-lt"><i class="ti ti-login"></i> Login</span>
{{else if eq .Action "login_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-login"></i> Login Failed</span>
{{else if eq .Action "logout"}}
<span class="badge bg-secondary-lt"><i class="ti ti-logout"></i> Logout</span>
{{else if eq .Action "mfa_verified"}}
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> MFA Verified</span>
{{else if eq .Action "mfa_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-shield-off"></i> MFA Failed</span>
{{else if eq .Action "mfa_enabled"}}
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> MFA Enabled</span>
{{else if eq .Action "mfa_disabled"}}
<span class="badge bg-yellow-lt"><i class="ti ti-shield-off"></i> MFA Disabled</span>
{{else if eq .Action "key_generated"}}
<span class="badge bg-blue-lt"><i class="ti ti-key"></i> Key Generated</span>
{{else if eq .Action "key_imported"}}
<span class="badge bg-blue-lt"><i class="ti ti-download"></i> Key Imported</span>
{{else if eq .Action "key_deleted"}}
<span class="badge bg-orange-lt"><i class="ti ti-trash"></i> Key Deleted</span>
{{else if eq .Action "key_downloaded"}}
<span class="badge bg-cyan-lt"><i class="ti ti-file-download"></i> Key Downloaded</span>
{{else if eq .Action "server_added"}}
<span class="badge bg-blue-lt"><i class="ti ti-server"></i> Server Added</span>
{{else if eq .Action "server_deleted"}}
<span class="badge bg-orange-lt"><i class="ti ti-trash"></i> Server Deleted</span>
{{else if eq .Action "server_test"}}
<span class="badge bg-cyan-lt"><i class="ti ti-plug"></i> Server Test</span>
{{else if eq .Action "server_auth_test"}}
<span class="badge bg-cyan-lt"><i class="ti ti-lock-check"></i> Auth Test</span>
{{else if eq .Action "deploy_success"}}
<span class="badge bg-green-lt"><i class="ti ti-send"></i> Deploy OK</span>
{{else if eq .Action "deploy_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-send-off"></i> Deploy Failed</span>
{{else if eq .Action "user_created"}}
<span class="badge bg-purple-lt"><i class="ti ti-user-plus"></i> User Created</span>
{{else if eq .Action "user_updated"}}
<span class="badge bg-purple-lt"><i class="ti ti-user-edit"></i> User Updated</span>
{{else if eq .Action "user_deleted"}}
<span class="badge bg-red-lt"><i class="ti ti-user-minus"></i> User Deleted</span>
{{else if eq .Action "settings_changed"}}
<span class="badge bg-yellow-lt"><i class="ti ti-settings"></i> Settings Changed</span>
{{else if eq .Action "password_changed"}}
<span class="badge bg-yellow-lt"><i class="ti ti-lock"></i> Password Changed</span>
{{else if eq .Action "masterkey_regenerated"}}
<span class="badge bg-yellow-lt"><i class="ti ti-refresh"></i> Master Key Regen</span>
{{else if eq .Action "masterkey_regen_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-key-off"></i> Master Key Regen Failed</span>
{{else if eq .Action "avatar_changed"}}
<span class="badge bg-cyan-lt"><i class="ti ti-photo"></i> Avatar Changed</span>
{{else if eq .Action "email_notify_changed"}}
<span class="badge bg-yellow-lt"><i class="ti ti-mail-cog"></i> Email Notify Changed</span>
{{else if eq .Action "email_test_sent"}}
<span class="badge bg-green-lt"><i class="ti ti-mail-check"></i> Email Test Sent</span>
{{else if eq .Action "email_test_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-mail-off"></i> Email Test Failed</span>
{{else if eq .Action "email_login_sent"}}
<span class="badge bg-green-lt"><i class="ti ti-mail-forward"></i> Login Email Sent</span>
{{else if eq .Action "email_login_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-mail-off"></i> Login Email Failed</span>
{{else if eq .Action "group_created"}}
<span class="badge bg-blue-lt"><i class="ti ti-folder-plus"></i> Group Created</span>
{{else if eq .Action "group_updated"}}
<span class="badge bg-blue-lt"><i class="ti ti-folder-cog"></i> Group Updated</span>
{{else if eq .Action "group_deleted"}}
<span class="badge bg-orange-lt"><i class="ti ti-folder-minus"></i> Group Deleted</span>
{{else if eq .Action "group_deploy"}}
<span class="badge bg-green-lt"><i class="ti ti-send"></i> Group Deploy</span>
{{else if eq .Action "server_updated"}}
<span class="badge bg-blue-lt"><i class="ti ti-server-cog"></i> Server Updated</span>
{{else if eq .Action "cron_job_created"}}
<span class="badge bg-blue-lt"><i class="ti ti-clock-plus"></i> Cron Created</span>
{{else if eq .Action "cron_job_updated"}}
<span class="badge bg-blue-lt"><i class="ti ti-clock-cog"></i> Cron Updated</span>
{{else if eq .Action "cron_job_deleted"}}
<span class="badge bg-orange-lt"><i class="ti ti-clock-minus"></i> Cron Deleted</span>
{{else if eq .Action "cron_job_paused"}}
<span class="badge bg-yellow-lt"><i class="ti ti-clock-pause"></i> Cron Paused</span>
{{else if eq .Action "cron_job_resumed"}}
<span class="badge bg-green-lt"><i class="ti ti-clock-play"></i> Cron Resumed</span>
{{else if eq .Action "cron_job_executed"}}
<span class="badge bg-green-lt"><i class="ti ti-clock-check"></i> Cron Executed</span>
{{else if eq .Action "cron_job_failed"}}
<span class="badge bg-red-lt"><i class="ti ti-clock-off"></i> Cron Failed</span>
{{else}}
<span class="badge bg-secondary-lt">{{.Action}}</span>
{{end}}
</td>
<td class="text-secondary text-break" style="max-width: 400px;">{{.Details}}</td>
<td class="text-secondary"><code>{{.IPAddress}}</code></td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center text-secondary py-4">
<i class="ti ti-mood-empty" style="font-size: 2rem;"></i><br>
No audit entries found.
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .AuditTotalPages 1}}
<div class="card-footer d-flex align-items-center">
<p class="m-0 text-secondary">
Page <strong>{{.AuditPage}}</strong> of <strong>{{.AuditTotalPages}}</strong>
</p>
<ul class="pagination m-0 ms-auto">
<li class="page-item {{if le .AuditPage 1}}disabled{{end}}">
<a class="page-link" href="/audit?page={{.AuditPrevPage}}{{if .AuditFilterUser}}&filter=mine{{end}}">
<i class="ti ti-chevron-left"></i> Prev
</a>
</li>
<li class="page-item {{if ge .AuditPage .AuditTotalPages}}disabled{{end}}">
<a class="page-link" href="/audit?page={{.AuditNextPage}}{{if .AuditFilterUser}}&filter=mine{{end}}">
Next <i class="ti ti-chevron-right"></i>
</a>
</li>
</ul>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
+181
View File
@@ -0,0 +1,181 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-clock"></i> Temporary Access</h3>
<div class="card-actions">
<a href="/cron/add" class="btn btn-primary">
<i class="ti ti-plus"></i> New Access
</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>User</th>
<th>Key</th>
<th>Target</th>
<th>System User</th>
<th>Password</th>
<th>Schedule</th>
<th>Auto-Remove</th>
<th>On Expiry</th>
<th>Next Run</th>
<th>Last Run</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{{range .CronJobs}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>
{{if .TargetUsername}}
<i class="ti ti-user"></i> {{.TargetUsername}}
{{else}}
<span class="text-secondary"></span>
{{end}}
</td>
<td><span class="badge bg-blue-lt">{{.KeyName}}</span></td>
<td>
{{if eq .TargetType "host"}}
<i class="ti ti-server"></i> {{.TargetName}}
{{else}}
<i class="ti ti-folders"></i> {{.TargetName}}
{{end}}
</td>
<td>
{{if .SystemUser}}
<code>{{.SystemUser}}</code>
{{else}}
<span class="text-secondary">root</span>
{{end}}
</td>
<td>
{{if .InitialPassword}}
<span class="d-inline-flex align-items-center">
<code class="cron-pw-hidden" id="cron-pw-hidden-{{.ID}}">••••••••••</code>
<code class="cron-pw-visible d-none" id="cron-pw-visible-{{.ID}}">{{.InitialPassword}}</code>
<button class="btn btn-sm btn-icon btn-ghost-secondary ms-1" type="button" onclick="toggleCronPassword({{.ID}})" title="Show/Hide password">
<i class="ti ti-eye"></i>
</button>
<button class="btn btn-sm btn-icon btn-ghost-secondary" type="button" onclick="navigator.clipboard.writeText(document.getElementById('cron-pw-visible-{{.ID}}').textContent); this.innerHTML='<i class=\'ti ti-check\'></i>'; setTimeout(()=>this.innerHTML='<i class=\'ti ti-copy\'></i>', 2000);" title="Copy password">
<i class="ti ti-copy"></i>
</button>
</span>
{{else}}
<span class="text-secondary"></span>
{{end}}
</td>
<td>
{{if eq .Schedule "once"}}
<span class="badge bg-cyan-lt"><i class="ti ti-clock"></i> Once</span>
{{else if eq .Schedule "hourly"}}
<span class="badge bg-orange-lt"><i class="ti ti-clock-hour-1"></i> Hourly at :{{printf "%02d" .MinuteOfHour}}</span>
{{else if eq .Schedule "daily"}}
<span class="badge bg-green-lt"><i class="ti ti-sun"></i> Daily at {{.TimeOfDay}}</span>
{{else if eq .Schedule "weekly"}}
<span class="badge bg-purple-lt"><i class="ti ti-calendar-week"></i> Weekly — {{.TimeOfDay}}</span>
{{else if eq .Schedule "monthly"}}
<span class="badge bg-pink-lt"><i class="ti ti-calendar"></i> Monthly on {{.DayOfMonth}}. — {{.TimeOfDay}}</span>
{{end}}
{{if and .Timezone (ne .Timezone "UTC")}}
<br><small class="text-secondary"><i class="ti ti-world"></i> {{.Timezone}}</small>
{{end}}
</td>
<td>
{{if gt .RemoveAfterMin 0}}
<span class="badge bg-yellow-lt">{{.RemoveAfterMin}} min</span>
{{else}}
<span class="text-secondary"></span>
{{end}}
</td>
<td>
{{if eq .ExpiryAction "remove_key"}}
<span class="badge bg-blue-lt"><i class="ti ti-key-off"></i> Remove Key</span>
{{else if eq .ExpiryAction "disable_user"}}
<span class="badge bg-warning-lt"><i class="ti ti-user-off"></i> Disable User</span>
{{else if eq .ExpiryAction "delete_user"}}
<span class="badge bg-danger-lt"><i class="ti ti-user-minus"></i> Delete User</span>
{{else}}
<span class="text-secondary"></span>
{{end}}
</td>
<td>
{{if eq .Status "done"}}
<span class="text-secondary"></span>
{{else}}
{{.NextRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
{{end}}
</td>
<td>
{{if .LastRun}}
{{.LastRun.Format "2006-01-02 15:04"}} <small class="text-secondary">UTC</small>
{{else}}
<span class="text-secondary">never</span>
{{end}}
</td>
<td>
{{if eq .Status "active"}}
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Active</span>
{{else if eq .Status "paused"}}
<span class="badge bg-warning-lt"><i class="ti ti-player-pause"></i> Paused</span>
{{else if eq .Status "running"}}
<span class="badge bg-blue-lt"><i class="ti ti-loader"></i> Running</span>
{{else if eq .Status "done"}}
<span class="badge bg-secondary-lt"><i class="ti ti-check"></i> Done</span>
{{else if eq .Status "failed"}}
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
{{end}}
</td>
<td class="text-end">
<div class="btn-list flex-nowrap justify-content-end">
{{if or (eq .Status "active") (eq .Status "paused")}}
<form method="POST" action="/cron/{{.ID}}/toggle" class="d-inline">
{{if eq .Status "active"}}
<button type="submit" class="btn btn-sm btn-icon btn-outline-warning" title="Pause">
<i class="ti ti-player-pause"></i>
</button>
{{else}}
<button type="submit" class="btn btn-sm btn-icon btn-outline-success" title="Resume">
<i class="ti ti-player-play"></i>
</button>
{{end}}
</form>
{{end}}
<a href="/cron/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
<i class="ti ti-edit"></i>
</a>
<form method="POST" action="/cron/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this job?');">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="13" class="text-center text-secondary">No temporary access jobs yet. Create one to grant time-limited access.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function toggleCronPassword(id) {
var hidden = document.getElementById('cron-pw-hidden-' + id);
var visible = document.getElementById('cron-pw-visible-' + id);
hidden.classList.toggle('d-none');
visible.classList.toggle('d-none');
}
</script>
{{end}}
+503
View File
@@ -0,0 +1,503 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-lg-8 col-xl-6 mx-auto">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-clock-plus"></i> New Temporary Access</h3>
</div>
<div class="card-body">
<form action="/cron/add" method="POST" id="cron-form">
<!-- Name -->
<div class="mb-3">
<label class="form-label required">Job Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. Temporary deploy access for contractor" required>
</div>
<!-- Target User -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-user"></i> Target User</label>
<select name="target_user_id" class="form-select" id="cron-target-user" required>
<option value="">Choose a user...</option>
{{range .AssignAllUsers}}
<option value="{{.ID}}">{{.Username}} ({{.Role}})</option>
{{end}}
</select>
<small class="form-hint">Select a KeyWarden user. Their SSH keys will be loaded.</small>
</div>
<!-- SSH Key (loaded via AJAX) -->
<div class="mb-3" id="cron-key-wrapper" style="display:none;">
<label class="form-label required"><i class="ti ti-key"></i> SSH Key</label>
<select name="key_id" class="form-select" id="cron-key-id" required>
<option value="">Loading keys...</option>
</select>
<small class="form-hint">Select the SSH key to deploy for this user.</small>
</div>
<!-- No Keys Warning -->
<div class="mb-3" id="cron-no-keys" style="display:none;">
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle me-2 fs-2"></i></div>
<div>
<h4 class="alert-title">No SSH Keys</h4>
<div>This user has no SSH keys. Please <a href="/keys/generate">generate</a> or <a href="/keys/import">import</a> a key first.</div>
</div>
</div>
</div>
</div>
<!-- Target Type -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-server"></i> Target</label>
<div class="row g-2 mb-2">
<div class="col-auto">
<label class="form-selectgroup-item" style="cursor:pointer;">
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" checked id="target-type-host">
<div class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</div>
</label>
</div>
<div class="col-auto">
<label class="form-selectgroup-item" style="cursor:pointer;">
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" id="target-type-group">
<div class="form-selectgroup-label"><i class="ti ti-folders"></i> Server Group</div>
</label>
</div>
</div>
<div id="target-host-select">
<select name="server_id" class="form-select" id="cron-server-id">
<option value="">Choose a server...</option>
{{range .Servers}}
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
{{end}}
</select>
</div>
<div id="target-group-select" style="display:none;">
<select name="group_id" class="form-select" id="cron-group-id">
<option value="">Choose a group...</option>
{{range .Groups}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<!-- System User -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-terminal-2"></i> System User</label>
<input type="text" name="system_user" class="form-control" placeholder="e.g. deploy, admin, root" required>
<small class="form-hint">The Linux user on the target server(s) that will receive the SSH key.</small>
</div>
<!-- Options: Sudo & Create User -->
<div class="mb-3">
<div class="row g-3">
<div class="col-auto">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="sudo" id="cron-sudo">
<span class="form-check-label">Grant Sudo</span>
</label>
</div>
<div class="col-auto">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="create_user" id="cron-create-user">
<span class="form-check-label">Create User if Missing</span>
</label>
</div>
</div>
</div>
<!-- Initial Password (shown when Create User is checked) -->
<div class="mb-3" id="cron-initial-pw-wrapper" style="display:none;">
<label class="form-label"><i class="ti ti-lock"></i> Initial Password</label>
<div class="alert alert-info mb-0">
<div class="d-flex align-items-center">
<i class="ti ti-info-circle me-2"></i>
<span>A secure initial password will be <strong>auto-generated</strong> when the user is created on the target host.</span>
</div>
</div>
</div>
<hr class="my-3">
<!-- Timezone -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-world"></i> Timezone</label>
<select name="timezone" class="form-select" id="cron-timezone" required>
<option value="UTC">UTC</option>
<option value="Europe/Berlin">Europe/Berlin (CET/CEST)</option>
<option value="Europe/London">Europe/London (GMT/BST)</option>
<option value="Europe/Paris">Europe/Paris (CET/CEST)</option>
<option value="Europe/Zurich">Europe/Zurich (CET/CEST)</option>
<option value="Europe/Vienna">Europe/Vienna (CET/CEST)</option>
<option value="Europe/Amsterdam">Europe/Amsterdam (CET/CEST)</option>
<option value="Europe/Warsaw">Europe/Warsaw (CET/CEST)</option>
<option value="Europe/Moscow">Europe/Moscow (MSK)</option>
<option value="America/New_York">America/New_York (EST/EDT)</option>
<option value="America/Chicago">America/Chicago (CST/CDT)</option>
<option value="America/Denver">America/Denver (MST/MDT)</option>
<option value="America/Los_Angeles">America/Los_Angeles (PST/PDT)</option>
<option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
<option value="Asia/Shanghai">Asia/Shanghai (CST)</option>
<option value="Asia/Kolkata">Asia/Kolkata (IST)</option>
<option value="Australia/Sydney">Australia/Sydney (AEST/AEDT)</option>
</select>
<small class="form-hint">All times will be interpreted in this timezone. Your browser timezone is auto-detected.</small>
</div>
<!-- Schedule Type -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-repeat"></i> Schedule</label>
<div class="row g-2">
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="once" class="form-selectgroup-input" checked>
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-clock d-block mb-1 fs-2"></i>
<strong>Once</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="hourly" class="form-selectgroup-input">
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-clock-hour-1 d-block mb-1 fs-2"></i>
<strong>Hourly</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="daily" class="form-selectgroup-input">
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-sun d-block mb-1 fs-2"></i>
<strong>Daily</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="weekly" class="form-selectgroup-input">
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-calendar-week d-block mb-1 fs-2"></i>
<strong>Weekly</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="monthly" class="form-selectgroup-input">
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-calendar d-block mb-1 fs-2"></i>
<strong>Monthly</strong>
</div>
</label>
</div>
</div>
</div>
<!-- Schedule: Once — full datetime picker -->
<div class="mb-3 schedule-option" id="sched-once">
<label class="form-label required">Date & Time</label>
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at">
<small class="form-hint">Select the exact date and time for this one-time job.</small>
</div>
<!-- Schedule: Hourly — minute of hour -->
<div class="mb-3 schedule-option" id="sched-hourly" style="display:none;">
<label class="form-label required">Run at minute</label>
<div class="row align-items-center">
<div class="col-auto">
<span class="text-secondary">Every hour at minute</span>
</div>
<div class="col-auto">
<select name="minute_of_hour" class="form-select" style="width:auto;" id="cron-minute-of-hour">
<option value="0">:00</option>
<option value="5">:05</option>
<option value="10">:10</option>
<option value="15">:15</option>
<option value="20">:20</option>
<option value="25">:25</option>
<option value="30">:30</option>
<option value="35">:35</option>
<option value="40">:40</option>
<option value="45">:45</option>
<option value="50">:50</option>
<option value="55">:55</option>
</select>
</div>
</div>
<small class="form-hint">The job will run every hour at the selected minute.</small>
</div>
<!-- Schedule: Daily — time of day -->
<div class="mb-3 schedule-option" id="sched-daily" style="display:none;">
<label class="form-label required">Time of Day</label>
<div class="row align-items-center">
<div class="col-auto">
<span class="text-secondary">Every day at</span>
</div>
<div class="col-auto">
<input type="time" class="form-control" id="cron-time-daily" value="02:00" style="width:auto;">
</div>
</div>
<small class="form-hint">The job will run every day at this time.</small>
</div>
<!-- Schedule: Weekly — day of week + time -->
<div class="mb-3 schedule-option" id="sched-weekly" style="display:none;">
<label class="form-label required">Day & Time</label>
<div class="row align-items-center g-2">
<div class="col-auto">
<span class="text-secondary">Every</span>
</div>
<div class="col-auto">
<select name="day_of_week" class="form-select" style="width:auto;" id="cron-day-of-week">
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
<option value="0">Sunday</option>
</select>
</div>
<div class="col-auto">
<span class="text-secondary">at</span>
</div>
<div class="col-auto">
<input type="time" class="form-control" id="cron-time-weekly" value="02:00" style="width:auto;">
</div>
</div>
<small class="form-hint">The job will run every week on the selected day and time.</small>
</div>
<!-- Schedule: Monthly — day of month + time -->
<div class="mb-3 schedule-option" id="sched-monthly" style="display:none;">
<label class="form-label required">Day & Time</label>
<div class="row align-items-center g-2">
<div class="col-auto">
<span class="text-secondary">On the</span>
</div>
<div class="col-auto">
<select name="day_of_month" class="form-select" style="width:auto;" id="cron-day-of-month">
{{range $i := .DaysOfMonth}}
<option value="{{$i}}">{{$i}}.</option>
{{end}}
</select>
</div>
<div class="col-auto">
<span class="text-secondary">of each month at</span>
</div>
<div class="col-auto">
<input type="time" class="form-control" id="cron-time-monthly" value="02:00" style="width:auto;">
</div>
</div>
<small class="form-hint">The job will run monthly on the selected day. If the day doesn't exist (e.g. 31st in February), it runs on the last day of the month.</small>
</div>
<!-- Next Run Preview -->
<div class="mb-3" id="next-run-preview" style="display:none;">
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle me-2 fs-2"></i></div>
<div>
<h4 class="alert-title">Schedule Preview</h4>
<div id="next-run-text"></div>
</div>
</div>
</div>
</div>
<hr class="my-3">
<!-- Auto-remove -->
<div class="mb-3">
<label class="form-label"><i class="ti ti-hourglass"></i> Auto-Remove After (minutes)</label>
<input type="number" name="remove_after_min" class="form-control" min="0" value="0" placeholder="0 = keep permanently">
<small class="form-hint">Set to 0 to keep the access permanently. E.g., 120 = revoke access after 2 hours.</small>
</div>
<!-- Expiry Action -->
<div class="mb-3">
<label class="form-label"><i class="ti ti-shield-off"></i> On Expiry</label>
<select name="expiry_action" class="form-select" id="cron-expiry-action">
<option value="remove_key" selected>Remove SSH Key only</option>
<option value="disable_user">Disable User (lock account + nologin)</option>
<option value="delete_user">Delete User (remove system user completely)</option>
</select>
<small class="form-hint">What happens when the temporary access expires.</small>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-clock-plus"></i> Create Temporary Access
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
(function() {
// Target user change: load SSH keys via AJAX
var userSelect = document.getElementById('cron-target-user');
var keySelect = document.getElementById('cron-key-id');
var keyWrapper = document.getElementById('cron-key-wrapper');
var noKeys = document.getElementById('cron-no-keys');
userSelect.addEventListener('change', function() {
var userId = this.value;
keyWrapper.style.display = 'none';
noKeys.style.display = 'none';
if (!userId) return;
keySelect.innerHTML = '<option value="">Loading...</option>';
keyWrapper.style.display = 'block';
fetch('/api/cron/keys?user_id=' + userId)
.then(function(r) { return r.json(); })
.then(function(data) {
var keys = data || [];
keySelect.innerHTML = '<option value="">Choose a key...</option>';
if (keys.length === 0) {
keyWrapper.style.display = 'none';
noKeys.style.display = 'block';
return;
}
keys.forEach(function(k) {
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = k.name + ' (' + k.key_type + ')';
keySelect.appendChild(opt);
});
})
.catch(function() {
keySelect.innerHTML = '<option value="">Failed to load keys</option>';
});
});
// Target type toggle
document.getElementById('target-type-host').addEventListener('change', function() {
document.getElementById('target-host-select').style.display = 'block';
document.getElementById('target-group-select').style.display = 'none';
});
document.getElementById('target-type-group').addEventListener('change', function() {
document.getElementById('target-host-select').style.display = 'none';
document.getElementById('target-group-select').style.display = 'block';
});
// Create user toggle → show initial password
document.getElementById('cron-create-user').addEventListener('change', function() {
document.getElementById('cron-initial-pw-wrapper').style.display = this.checked ? 'block' : 'none';
});
// Schedule type toggle
var scheduleOptions = ['once', 'hourly', 'daily', 'weekly', 'monthly'];
function updateScheduleUI() {
var selected = document.querySelector('input[name="schedule"]:checked').value;
scheduleOptions.forEach(function(opt) {
var el = document.getElementById('sched-' + opt);
if (el) el.style.display = (opt === selected) ? 'block' : 'none';
});
updatePreview();
}
document.querySelectorAll('input[name="schedule"]').forEach(function(el) {
el.addEventListener('change', updateScheduleUI);
});
// Auto-detect timezone
try {
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
var tzSelect = document.getElementById('cron-timezone');
for (var i = 0; i < tzSelect.options.length; i++) {
if (tzSelect.options[i].value === tz) {
tzSelect.value = tz;
break;
}
}
} catch(e) {}
// Preview generation
var dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function updatePreview() {
var schedule = document.querySelector('input[name="schedule"]:checked').value;
var tz = document.getElementById('cron-timezone').value;
var previewEl = document.getElementById('next-run-preview');
var textEl = document.getElementById('next-run-text');
var text = '';
switch(schedule) {
case 'once':
var dt = document.getElementById('cron-scheduled-at').value;
if (dt) {
text = 'Runs once on <strong>' + dt.replace('T', ' at ') + '</strong> (' + tz + ')';
}
break;
case 'hourly':
var min = document.getElementById('cron-minute-of-hour').value;
text = 'Runs <strong>every hour at :' + String(min).padStart(2, '0') + '</strong> (' + tz + ')';
break;
case 'daily':
var time = document.getElementById('cron-time-daily').value || '02:00';
text = 'Runs <strong>every day at ' + time + '</strong> (' + tz + ')';
break;
case 'weekly':
var dow = document.getElementById('cron-day-of-week').value;
var time = document.getElementById('cron-time-weekly').value || '02:00';
text = 'Runs <strong>every ' + dayNames[dow] + ' at ' + time + '</strong> (' + tz + ')';
break;
case 'monthly':
var dom = document.getElementById('cron-day-of-month').value;
var time = document.getElementById('cron-time-monthly').value || '02:00';
text = 'Runs <strong>on the ' + dom + '. of each month at ' + time + '</strong> (' + tz + ')';
break;
}
if (text) {
previewEl.style.display = 'block';
textEl.innerHTML = text;
} else {
previewEl.style.display = 'none';
}
}
// Listen for changes on all schedule inputs
document.querySelectorAll('#cron-scheduled-at, #cron-minute-of-hour, #cron-time-daily, #cron-time-weekly, #cron-day-of-week, #cron-time-monthly, #cron-day-of-month, #cron-timezone').forEach(function(el) {
el.addEventListener('change', updatePreview);
el.addEventListener('input', updatePreview);
});
// Consolidate time_of_day fields before submit
document.getElementById('cron-form').addEventListener('submit', function(e) {
var schedule = document.querySelector('input[name="schedule"]:checked').value;
var existing = document.querySelector('input[name="time_of_day"][type="hidden"]');
if (existing) existing.remove();
var hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'time_of_day';
if (schedule === 'daily') {
hidden.value = document.getElementById('cron-time-daily').value || '02:00';
} else if (schedule === 'weekly') {
hidden.value = document.getElementById('cron-time-weekly').value || '02:00';
} else if (schedule === 'monthly') {
hidden.value = document.getElementById('cron-time-monthly').value || '02:00';
} else {
hidden.value = '00:00';
}
this.appendChild(hidden);
});
// Initialize
updateScheduleUI();
})();
</script>
{{end}}
+510
View File
@@ -0,0 +1,510 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-lg-8 col-xl-6 mx-auto">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-clock-edit"></i> Edit Temporary Access</h3>
</div>
<div class="card-body">
{{$job := .CronJob}}
<form action="/cron/{{$job.ID}}/edit" method="POST" id="cron-form">
<!-- Name -->
<div class="mb-3">
<label class="form-label required">Job Name</label>
<input type="text" name="name" class="form-control" value="{{$job.Name}}" required>
</div>
<!-- Target User -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-user"></i> Target User</label>
<select name="target_user_id" class="form-select" id="cron-target-user" required>
<option value="">Choose a user...</option>
{{range $.AssignAllUsers}}
<option value="{{.ID}}" {{if eq .ID $job.TargetUserID}}selected{{end}}>{{.Username}} ({{.Role}})</option>
{{end}}
</select>
<small class="form-hint">Select a KeyWarden user. Their SSH keys will be loaded.</small>
</div>
<!-- SSH Key (loaded via AJAX) -->
<div class="mb-3" id="cron-key-wrapper" {{if le $job.SSHKeyID 0}}style="display:none;"{{end}}>
<label class="form-label required"><i class="ti ti-key"></i> SSH Key</label>
<select name="key_id" class="form-select" id="cron-key-id" required>
<option value="">Loading keys...</option>
</select>
<small class="form-hint">Select the SSH key to deploy for this user.</small>
</div>
<!-- No Keys Warning -->
<div class="mb-3" id="cron-no-keys" style="display:none;">
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle me-2 fs-2"></i></div>
<div>
<h4 class="alert-title">No SSH Keys</h4>
<div>This user has no SSH keys. Please <a href="/keys/generate">generate</a> or <a href="/keys/import">import</a> a key first.</div>
</div>
</div>
</div>
</div>
<!-- Target Type -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-server"></i> Target</label>
<div class="row g-2 mb-2">
<div class="col-auto">
<label class="form-selectgroup-item" style="cursor:pointer;">
<input type="radio" name="target_type" value="host" class="form-selectgroup-input" {{if or (gt $job.ServerID 0) (eq $job.GroupID 0)}}checked{{end}} id="target-type-host">
<div class="form-selectgroup-label"><i class="ti ti-server"></i> Single Host</div>
</label>
</div>
<div class="col-auto">
<label class="form-selectgroup-item" style="cursor:pointer;">
<input type="radio" name="target_type" value="group" class="form-selectgroup-input" {{if gt $job.GroupID 0}}checked{{end}} id="target-type-group">
<div class="form-selectgroup-label"><i class="ti ti-folders"></i> Server Group</div>
</label>
</div>
</div>
<div id="target-host-select" {{if gt $job.GroupID 0}}style="display:none;"{{end}}>
<select name="server_id" class="form-select" id="cron-server-id">
<option value="">Choose a server...</option>
{{range $.Servers}}
<option value="{{.ID}}" {{if eq .ID $job.ServerID}}selected{{end}}>{{.Name}} ({{.Hostname}}:{{.Port}})</option>
{{end}}
</select>
</div>
<div id="target-group-select" {{if le $job.GroupID 0}}style="display:none;"{{end}}>
<select name="group_id" class="form-select" id="cron-group-id">
<option value="">Choose a group...</option>
{{range $.Groups}}
<option value="{{.ID}}" {{if eq .ID $job.GroupID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<!-- System User -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-terminal-2"></i> System User</label>
<input type="text" name="system_user" class="form-control" value="{{$job.SystemUser}}" placeholder="e.g. deploy, admin, root" required>
<small class="form-hint">The Linux user on the target server(s) that will receive the SSH key.</small>
</div>
<!-- Options: Sudo & Create User -->
<div class="mb-3">
<div class="row g-3">
<div class="col-auto">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="sudo" id="cron-sudo" {{if $job.Sudo}}checked{{end}}>
<span class="form-check-label">Grant Sudo</span>
</label>
</div>
<div class="col-auto">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="create_user" id="cron-create-user" {{if $job.CreateUser}}checked{{end}}>
<span class="form-check-label">Create User if Missing</span>
</label>
</div>
</div>
</div>
<!-- Initial Password (shown when Create User is checked) -->
<div class="mb-3" id="cron-initial-pw-wrapper" {{if not $job.CreateUser}}style="display:none;"{{end}}>
<label class="form-label"><i class="ti ti-lock"></i> Initial Password</label>
<div class="alert alert-info mb-0">
<div class="d-flex align-items-center">
<i class="ti ti-info-circle me-2"></i>
<span>A secure initial password will be <strong>auto-generated</strong> when the user is created on the target host.</span>
</div>
</div>
</div>
<hr class="my-3">
<!-- Timezone -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-world"></i> Timezone</label>
<select name="timezone" class="form-select" id="cron-timezone" required>
<option value="UTC" {{if eq $job.Timezone "UTC"}}selected{{end}}>UTC</option>
<option value="Europe/Berlin" {{if eq $job.Timezone "Europe/Berlin"}}selected{{end}}>Europe/Berlin (CET/CEST)</option>
<option value="Europe/London" {{if eq $job.Timezone "Europe/London"}}selected{{end}}>Europe/London (GMT/BST)</option>
<option value="Europe/Paris" {{if eq $job.Timezone "Europe/Paris"}}selected{{end}}>Europe/Paris (CET/CEST)</option>
<option value="Europe/Zurich" {{if eq $job.Timezone "Europe/Zurich"}}selected{{end}}>Europe/Zurich (CET/CEST)</option>
<option value="Europe/Vienna" {{if eq $job.Timezone "Europe/Vienna"}}selected{{end}}>Europe/Vienna (CET/CEST)</option>
<option value="Europe/Amsterdam" {{if eq $job.Timezone "Europe/Amsterdam"}}selected{{end}}>Europe/Amsterdam (CET/CEST)</option>
<option value="Europe/Warsaw" {{if eq $job.Timezone "Europe/Warsaw"}}selected{{end}}>Europe/Warsaw (CET/CEST)</option>
<option value="Europe/Moscow" {{if eq $job.Timezone "Europe/Moscow"}}selected{{end}}>Europe/Moscow (MSK)</option>
<option value="America/New_York" {{if eq $job.Timezone "America/New_York"}}selected{{end}}>America/New_York (EST/EDT)</option>
<option value="America/Chicago" {{if eq $job.Timezone "America/Chicago"}}selected{{end}}>America/Chicago (CST/CDT)</option>
<option value="America/Denver" {{if eq $job.Timezone "America/Denver"}}selected{{end}}>America/Denver (MST/MDT)</option>
<option value="America/Los_Angeles" {{if eq $job.Timezone "America/Los_Angeles"}}selected{{end}}>America/Los_Angeles (PST/PDT)</option>
<option value="Asia/Tokyo" {{if eq $job.Timezone "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo (JST)</option>
<option value="Asia/Shanghai" {{if eq $job.Timezone "Asia/Shanghai"}}selected{{end}}>Asia/Shanghai (CST)</option>
<option value="Asia/Kolkata" {{if eq $job.Timezone "Asia/Kolkata"}}selected{{end}}>Asia/Kolkata (IST)</option>
<option value="Australia/Sydney" {{if eq $job.Timezone "Australia/Sydney"}}selected{{end}}>Australia/Sydney (AEST/AEDT)</option>
</select>
<small class="form-hint">All times will be interpreted in this timezone.</small>
</div>
<!-- Schedule Type -->
<div class="mb-3">
<label class="form-label required"><i class="ti ti-repeat"></i> Schedule</label>
<div class="row g-2">
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="once" class="form-selectgroup-input" {{if eq $job.Schedule "once"}}checked{{end}}>
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-clock d-block mb-1 fs-2"></i>
<strong>Once</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="hourly" class="form-selectgroup-input" {{if eq $job.Schedule "hourly"}}checked{{end}}>
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-clock-hour-1 d-block mb-1 fs-2"></i>
<strong>Hourly</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="daily" class="form-selectgroup-input" {{if eq $job.Schedule "daily"}}checked{{end}}>
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-sun d-block mb-1 fs-2"></i>
<strong>Daily</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="weekly" class="form-selectgroup-input" {{if eq $job.Schedule "weekly"}}checked{{end}}>
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-calendar-week d-block mb-1 fs-2"></i>
<strong>Weekly</strong>
</div>
</label>
</div>
<div class="col">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="schedule" value="monthly" class="form-selectgroup-input" {{if eq $job.Schedule "monthly"}}checked{{end}}>
<div class="form-selectgroup-label text-center p-2">
<i class="ti ti-calendar d-block mb-1 fs-2"></i>
<strong>Monthly</strong>
</div>
</label>
</div>
</div>
</div>
<!-- Schedule: Once — full datetime picker -->
<div class="mb-3 schedule-option" id="sched-once" {{if ne $job.Schedule "once"}}style="display:none;"{{end}}>
<label class="form-label required">Date & Time</label>
<input type="datetime-local" name="scheduled_at" class="form-control" id="cron-scheduled-at"
value="{{$job.ScheduledAt.Format "2006-01-02T15:04"}}">
<small class="form-hint">Select the exact date and time for this one-time job.</small>
</div>
<!-- Schedule: Hourly — minute of hour -->
<div class="mb-3 schedule-option" id="sched-hourly" {{if ne $job.Schedule "hourly"}}style="display:none;"{{end}}>
<label class="form-label required">Run at minute</label>
<div class="row align-items-center">
<div class="col-auto">
<span class="text-secondary">Every hour at minute</span>
</div>
<div class="col-auto">
<select name="minute_of_hour" class="form-select" style="width:auto;" id="cron-minute-of-hour">
<option value="0" {{if eq $job.MinuteOfHour 0}}selected{{end}}>:00</option>
<option value="5" {{if eq $job.MinuteOfHour 5}}selected{{end}}>:05</option>
<option value="10" {{if eq $job.MinuteOfHour 10}}selected{{end}}>:10</option>
<option value="15" {{if eq $job.MinuteOfHour 15}}selected{{end}}>:15</option>
<option value="20" {{if eq $job.MinuteOfHour 20}}selected{{end}}>:20</option>
<option value="25" {{if eq $job.MinuteOfHour 25}}selected{{end}}>:25</option>
<option value="30" {{if eq $job.MinuteOfHour 30}}selected{{end}}>:30</option>
<option value="35" {{if eq $job.MinuteOfHour 35}}selected{{end}}>:35</option>
<option value="40" {{if eq $job.MinuteOfHour 40}}selected{{end}}>:40</option>
<option value="45" {{if eq $job.MinuteOfHour 45}}selected{{end}}>:45</option>
<option value="50" {{if eq $job.MinuteOfHour 50}}selected{{end}}>:50</option>
<option value="55" {{if eq $job.MinuteOfHour 55}}selected{{end}}>:55</option>
</select>
</div>
</div>
<small class="form-hint">The job will run every hour at the selected minute.</small>
</div>
<!-- Schedule: Daily — time of day -->
<div class="mb-3 schedule-option" id="sched-daily" {{if ne $job.Schedule "daily"}}style="display:none;"{{end}}>
<label class="form-label required">Time of Day</label>
<div class="row align-items-center">
<div class="col-auto">
<span class="text-secondary">Every day at</span>
</div>
<div class="col-auto">
<input type="time" class="form-control" id="cron-time-daily"
value="{{$job.TimeOfDay}}" style="width:auto;">
</div>
</div>
<small class="form-hint">The job will run every day at this time.</small>
</div>
<!-- Schedule: Weekly — day of week + time -->
<div class="mb-3 schedule-option" id="sched-weekly" {{if ne $job.Schedule "weekly"}}style="display:none;"{{end}}>
<label class="form-label required">Day & Time</label>
<div class="row align-items-center g-2">
<div class="col-auto">
<span class="text-secondary">Every</span>
</div>
<div class="col-auto">
<select name="day_of_week" class="form-select" style="width:auto;" id="cron-day-of-week">
<option value="1" {{if eq $job.DayOfWeek 1}}selected{{end}}>Monday</option>
<option value="2" {{if eq $job.DayOfWeek 2}}selected{{end}}>Tuesday</option>
<option value="3" {{if eq $job.DayOfWeek 3}}selected{{end}}>Wednesday</option>
<option value="4" {{if eq $job.DayOfWeek 4}}selected{{end}}>Thursday</option>
<option value="5" {{if eq $job.DayOfWeek 5}}selected{{end}}>Friday</option>
<option value="6" {{if eq $job.DayOfWeek 6}}selected{{end}}>Saturday</option>
<option value="0" {{if eq $job.DayOfWeek 0}}selected{{end}}>Sunday</option>
</select>
</div>
<div class="col-auto">
<span class="text-secondary">at</span>
</div>
<div class="col-auto">
<input type="time" class="form-control" id="cron-time-weekly"
value="{{$job.TimeOfDay}}" style="width:auto;">
</div>
</div>
<small class="form-hint">The job will run every week on the selected day and time.</small>
</div>
<!-- Schedule: Monthly — day of month + time -->
<div class="mb-3 schedule-option" id="sched-monthly" {{if ne $job.Schedule "monthly"}}style="display:none;"{{end}}>
<label class="form-label required">Day & Time</label>
<div class="row align-items-center g-2">
<div class="col-auto">
<span class="text-secondary">On the</span>
</div>
<div class="col-auto">
<select name="day_of_month" class="form-select" style="width:auto;" id="cron-day-of-month">
{{range $i := $.DaysOfMonth}}
<option value="{{$i}}" {{if eq $i $job.DayOfMonth}}selected{{end}}>{{$i}}.</option>
{{end}}
</select>
</div>
<div class="col-auto">
<span class="text-secondary">of each month at</span>
</div>
<div class="col-auto">
<input type="time" class="form-control" id="cron-time-monthly"
value="{{$job.TimeOfDay}}" style="width:auto;">
</div>
</div>
<small class="form-hint">The job will run monthly on the selected day. If the day doesn't exist (e.g. 31st in February), it runs on the last day of the month.</small>
</div>
<!-- Next Run Preview -->
<div class="mb-3" id="next-run-preview" style="display:none;">
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle me-2 fs-2"></i></div>
<div>
<h4 class="alert-title">Schedule Preview</h4>
<div id="next-run-text"></div>
</div>
</div>
</div>
</div>
<hr class="my-3">
<!-- Auto-remove -->
<div class="mb-3">
<label class="form-label"><i class="ti ti-hourglass"></i> Auto-Remove After (minutes)</label>
<input type="number" name="remove_after_min" class="form-control" min="0" value="{{$job.RemoveAfterMin}}" placeholder="0 = keep permanently">
<small class="form-hint">Set to 0 to keep the access permanently. E.g., 120 = revoke access after 2 hours.</small>
</div>
<!-- Expiry Action -->
<div class="mb-3">
<label class="form-label"><i class="ti ti-shield-off"></i> On Expiry</label>
<select name="expiry_action" class="form-select" id="cron-expiry-action">
<option value="remove_key" {{if eq $job.ExpiryAction "remove_key"}}selected{{end}}>Remove SSH Key only</option>
<option value="disable_user" {{if eq $job.ExpiryAction "disable_user"}}selected{{end}}>Disable User (lock account + nologin)</option>
<option value="delete_user" {{if eq $job.ExpiryAction "delete_user"}}selected{{end}}>Delete User (remove system user completely)</option>
</select>
<small class="form-hint">What happens when the temporary access expires.</small>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-device-floppy"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
(function() {
var preselectedKeyId = {{$job.SSHKeyID}};
var preselectedUserId = {{$job.TargetUserID}};
// Target user change: load SSH keys via AJAX
var userSelect = document.getElementById('cron-target-user');
var keySelect = document.getElementById('cron-key-id');
var keyWrapper = document.getElementById('cron-key-wrapper');
var noKeys = document.getElementById('cron-no-keys');
function loadKeys(userId, preselectId) {
keyWrapper.style.display = 'none';
noKeys.style.display = 'none';
if (!userId) return;
keySelect.innerHTML = '<option value="">Loading...</option>';
keyWrapper.style.display = 'block';
fetch('/api/cron/keys?user_id=' + userId)
.then(function(r) { return r.json(); })
.then(function(data) {
var keys = data || [];
keySelect.innerHTML = '<option value="">Choose a key...</option>';
if (keys.length === 0) {
keyWrapper.style.display = 'none';
noKeys.style.display = 'block';
return;
}
keys.forEach(function(k) {
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = k.name + ' (' + k.key_type + ')';
if (preselectId && k.id === preselectId) {
opt.selected = true;
}
keySelect.appendChild(opt);
});
})
.catch(function() {
keySelect.innerHTML = '<option value="">Failed to load keys</option>';
});
}
userSelect.addEventListener('change', function() {
loadKeys(this.value, null);
});
// Load keys for preselected user on page load
if (preselectedUserId > 0) {
loadKeys(preselectedUserId, preselectedKeyId);
}
// Target type toggle
document.getElementById('target-type-host').addEventListener('change', function() {
document.getElementById('target-host-select').style.display = 'block';
document.getElementById('target-group-select').style.display = 'none';
});
document.getElementById('target-type-group').addEventListener('change', function() {
document.getElementById('target-host-select').style.display = 'none';
document.getElementById('target-group-select').style.display = 'block';
});
// Create user toggle → show initial password
document.getElementById('cron-create-user').addEventListener('change', function() {
document.getElementById('cron-initial-pw-wrapper').style.display = this.checked ? 'block' : 'none';
});
// Schedule type toggle
var scheduleOptions = ['once', 'hourly', 'daily', 'weekly', 'monthly'];
function updateScheduleUI() {
var selected = document.querySelector('input[name="schedule"]:checked').value;
scheduleOptions.forEach(function(opt) {
var el = document.getElementById('sched-' + opt);
if (el) el.style.display = (opt === selected) ? 'block' : 'none';
});
updatePreview();
}
document.querySelectorAll('input[name="schedule"]').forEach(function(el) {
el.addEventListener('change', updateScheduleUI);
});
// Preview generation
var dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function updatePreview() {
var schedule = document.querySelector('input[name="schedule"]:checked').value;
var tz = document.getElementById('cron-timezone').value;
var previewEl = document.getElementById('next-run-preview');
var textEl = document.getElementById('next-run-text');
var text = '';
switch(schedule) {
case 'once':
var dt = document.getElementById('cron-scheduled-at').value;
if (dt) {
text = 'Runs once on <strong>' + dt.replace('T', ' at ') + '</strong> (' + tz + ')';
}
break;
case 'hourly':
var min = document.getElementById('cron-minute-of-hour').value;
text = 'Runs <strong>every hour at :' + String(min).padStart(2, '0') + '</strong> (' + tz + ')';
break;
case 'daily':
var time = document.getElementById('cron-time-daily').value || '02:00';
text = 'Runs <strong>every day at ' + time + '</strong> (' + tz + ')';
break;
case 'weekly':
var dow = document.getElementById('cron-day-of-week').value;
var time = document.getElementById('cron-time-weekly').value || '02:00';
text = 'Runs <strong>every ' + dayNames[dow] + ' at ' + time + '</strong> (' + tz + ')';
break;
case 'monthly':
var dom = document.getElementById('cron-day-of-month').value;
var time = document.getElementById('cron-time-monthly').value || '02:00';
text = 'Runs <strong>on the ' + dom + '. of each month at ' + time + '</strong> (' + tz + ')';
break;
}
if (text) {
previewEl.style.display = 'block';
textEl.innerHTML = text;
} else {
previewEl.style.display = 'none';
}
}
// Listen for changes on all schedule inputs
document.querySelectorAll('#cron-scheduled-at, #cron-minute-of-hour, #cron-time-daily, #cron-time-weekly, #cron-day-of-week, #cron-time-monthly, #cron-day-of-month, #cron-timezone').forEach(function(el) {
el.addEventListener('change', updatePreview);
el.addEventListener('input', updatePreview);
});
// Consolidate time_of_day fields before submit
document.getElementById('cron-form').addEventListener('submit', function(e) {
var schedule = document.querySelector('input[name="schedule"]:checked').value;
var existing = document.querySelector('input[name="time_of_day"][type="hidden"]');
if (existing) existing.remove();
var hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'time_of_day';
if (schedule === 'daily') {
hidden.value = document.getElementById('cron-time-daily').value || '02:00';
} else if (schedule === 'weekly') {
hidden.value = document.getElementById('cron-time-weekly').value || '02:00';
} else if (schedule === 'monthly') {
hidden.value = document.getElementById('cron-time-monthly').value || '02:00';
} else {
hidden.value = '00:00';
}
this.appendChild(hidden);
});
// Initialize
updateScheduleUI();
})();
</script>
{{end}}
+223
View File
@@ -0,0 +1,223 @@
{{define "content"}}
<div class="row row-deck row-cards g-3">
<!-- ═══ Stat Cards Row ═══ -->
<div class="col-6 col-sm-4 col-lg-2">
<div class="card">
<div class="card-body text-center">
<div class="text-secondary mb-1"><i class="ti ti-key"></i> SSH Keys</div>
<div class="h1 mb-0">{{.KeyCount}}</div>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card">
<div class="card-body text-center">
<div class="text-secondary mb-1"><i class="ti ti-server"></i> Hosts</div>
<div class="h1 mb-0">{{.ServerCount}}</div>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card">
<div class="card-body text-center">
<div class="text-secondary mb-1"><i class="ti ti-folders"></i> Server Groups</div>
<div class="h1 mb-0">{{.GroupCount}}</div>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card">
<div class="card-body text-center">
<div class="text-secondary mb-1"><i class="ti ti-send"></i> Deployments</div>
<div class="h1 mb-0">{{.DeployCount}}</div>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card">
<div class="card-body text-center">
<div class="text-secondary mb-1"><i class="ti ti-shield-lock"></i> Assignments</div>
<div class="h1 mb-0">{{.AssignmentCount}}</div>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card">
<div class="card-body text-center">
{{if eq .UserRole "admin"}}
<div class="text-secondary mb-1"><i class="ti ti-users"></i> Users</div>
<div class="h1 mb-0">{{.UserCount}}</div>
{{else}}
<div class="text-secondary mb-1"><i class="ti ti-clock"></i> Temporary Access</div>
<div class="h1 mb-0">{{.CronCount}}</div>
{{end}}
</div>
</div>
</div>
<!-- ═══ Quick Actions ═══ -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-bolt"></i> Quick Actions</h3>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="/keys/generate" class="btn btn-outline-primary w-100">
<i class="ti ti-key"></i> Generate Key
</a>
</div>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="/keys/import" class="btn btn-outline-primary w-100">
<i class="ti ti-file-import"></i> Import Key
</a>
</div>
{{if ne .UserRole "user"}}
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="/servers/add" class="btn btn-outline-primary w-100">
<i class="ti ti-server"></i> Add Host
</a>
</div>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="/deploy" class="btn btn-outline-primary w-100">
<i class="ti ti-send"></i> Deploy Keys
</a>
</div>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="/cron/add" class="btn btn-outline-primary w-100">
<i class="ti ti-clock-plus"></i> Temporary Access
</a>
</div>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="/assignments/add" class="btn btn-outline-primary w-100">
<i class="ti ti-shield-plus"></i> New Assignment
</a>
</div>
{{end}}
</div>
</div>
</div>
</div>
<!-- ═══ Recent SSH Keys ═══ -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-key"></i> Recent SSH Keys</h3>
<div class="card-actions">
<a href="/keys" class="btn btn-outline-primary btn-sm">View All</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Fingerprint</th>
</tr>
</thead>
<tbody>
{{range .RecentKeys}}
<tr>
<td>{{.Name}}</td>
<td><span class="badge bg-azure-lt">{{.KeyType}}</span></td>
<td><code>{{.Fingerprint}}</code></td>
</tr>
{{else}}
<tr>
<td colspan="3" class="text-center text-secondary">No SSH keys yet. <a href="/keys/generate">Generate one!</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ Recent Deployments ═══ -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-send"></i> Recent Deployments</h3>
<div class="card-actions">
<a href="/deploy" class="btn btn-outline-primary btn-sm">View All</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Key</th>
<th>Server</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{{range .RecentDeploys}}
<tr>
<td>{{index . "key_name"}}</td>
<td>{{index . "server_name"}}</td>
<td>
{{if eq (index . "status") "success"}}
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Success</span>
{{else}}
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
{{end}}
</td>
</tr>
{{else}}
<tr>
<td colspan="3" class="text-center text-secondary">No deployments yet.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ Recent Activity (Audit Log) ═══ -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-history"></i> Recent Activity</h3>
<div class="card-actions">
<a href="/audit" class="btn btn-outline-primary btn-sm">View All</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Action</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{{range .RecentAudit}}
<tr>
<td class="text-nowrap">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{.Username}}</td>
<td><span class="badge bg-secondary-lt">{{.Action}}</span></td>
<td class="text-truncate" style="max-width: 300px;">{{.Details}}</td>
<td><code>{{.IPAddress}}</code></td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center text-secondary">No activity recorded yet.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
+277
View File
@@ -0,0 +1,277 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-send"></i> Deploy SSH Key</h3>
</div>
<div class="card-body">
<!-- Step 1: Target Type -->
<div class="mb-4">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">1</span> Deploy Target
</label>
<div class="row g-3">
<div class="col-6">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="deploy_target" value="host" class="form-selectgroup-input" id="target-host">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3"><i class="ti ti-server fs-2"></i></div>
<div>
<strong>Single Host</strong>
<div class="text-secondary">Deploy to one server</div>
</div>
</div>
</label>
</div>
<div class="col-6">
<label class="form-selectgroup-item w-100" style="cursor:pointer;">
<input type="radio" name="deploy_target" value="group" class="form-selectgroup-input" id="target-group">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3"><i class="ti ti-folders fs-2"></i></div>
<div>
<strong>Server Group</strong>
<div class="text-secondary">Deploy to all servers in a group</div>
</div>
</div>
</label>
</div>
</div>
</div>
<!-- Steps 2-4 (hidden until target is chosen) -->
<div id="deploy-details" style="display:none;">
<!-- Host form (POST to /deploy) -->
<form action="/deploy" method="POST" id="form-host" style="display:none;">
<!-- Step 2: Key -->
<div class="mb-3">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">2</span> SSH Key
</label>
<select name="key_id" class="form-select" required>
<option value="">Choose a key...</option>
{{range .Keys}}
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
{{end}}
</select>
</div>
<!-- Step 3: Host -->
<div class="mb-3">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">3</span> Target Host
</label>
<select name="server_id" class="form-select" required>
<option value="">Choose a host...</option>
{{range .Servers}}
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
{{end}}
</select>
</div>
<!-- Step 4: Auth -->
<div class="mb-3">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">4</span> Authentication Method
</label>
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
<label class="form-selectgroup-item flex-fill">
<input type="radio" name="auth_method" value="password" class="form-selectgroup-input host-auth" checked>
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3"><i class="ti ti-lock"></i></div>
<div>
<strong>Password</strong>
<div class="text-secondary">Use SSH password to authenticate</div>
</div>
</div>
</label>
<label class="form-selectgroup-item flex-fill">
<input type="radio" name="auth_method" value="key" class="form-selectgroup-input host-auth">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3"><i class="ti ti-key"></i></div>
<div>
<strong>Existing Key</strong>
<div class="text-secondary">Use an existing key from Keywarden to authenticate</div>
</div>
</div>
</label>
</div>
</div>
<div class="mb-3" id="host-password-group">
<label class="form-label">SSH Password</label>
<input type="password" name="password" class="form-control" placeholder="Server password">
</div>
<div class="mb-3" id="host-authkey-group" style="display:none;">
<label class="form-label">Authentication Key</label>
<select name="auth_key_id" class="form-select">
<option value="">Choose a key for auth...</option>
{{range .Keys}}
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
{{end}}
</select>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-send"></i> Deploy Key to Host
</button>
</div>
</form>
<!-- Group form (POST to /deploy/group) -->
<form action="/deploy/group" method="POST" id="form-group" style="display:none;">
<!-- Step 2: Key -->
<div class="mb-3">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">2</span> SSH Key
</label>
<select name="key_id" class="form-select" required>
<option value="">Choose a key...</option>
{{range .Keys}}
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
{{end}}
</select>
</div>
<!-- Step 3: Group -->
<div class="mb-3">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">3</span> Target Group
</label>
<select name="group_id" class="form-select" required>
<option value="">Choose a group...</option>
{{range .Groups}}
<option value="{{.ID}}">{{.Name}} ({{.ServerCount}} server{{if ne .ServerCount 1}}s{{end}})</option>
{{end}}
</select>
</div>
<!-- Step 4: Auth -->
<div class="mb-3">
<label class="form-label required">
<span class="badge bg-blue-lt me-1">4</span> Authentication Method
</label>
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
<label class="form-selectgroup-item flex-fill">
<input type="radio" name="auth_method" value="password" class="form-selectgroup-input grp-auth" checked>
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3"><i class="ti ti-lock"></i></div>
<div>
<strong>Password</strong>
<div class="text-secondary">Same password for all hosts in group</div>
</div>
</div>
</label>
<label class="form-selectgroup-item flex-fill">
<input type="radio" name="auth_method" value="key" class="form-selectgroup-input grp-auth">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3"><i class="ti ti-key"></i></div>
<div>
<strong>Existing Key</strong>
<div class="text-secondary">Use an existing key from Keywarden</div>
</div>
</div>
</label>
</div>
</div>
<div class="mb-3" id="grp-password-group">
<label class="form-label">SSH Password</label>
<input type="password" name="password" class="form-control" placeholder="Server password">
</div>
<div class="mb-3" id="grp-authkey-group" style="display:none;">
<label class="form-label">Authentication Key</label>
<select name="auth_key_id" class="form-select">
<option value="">Choose a key for auth...</option>
{{range .Keys}}
<option value="{{.ID}}">{{.Name}} ({{.KeyType}})</option>
{{end}}
</select>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-send"></i> Deploy Key to Group
</button>
</div>
</form>
</div><!-- /deploy-details -->
</div>
</div>
</div>
<!-- Deployment History -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-history"></i> Deployment History</h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Key</th>
<th>Server</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{{range .Deployments}}
<tr>
<td>{{index . "key_name"}}</td>
<td>{{index . "server_name"}}</td>
<td>
{{if eq (index . "status") "success"}}
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Success</span>
{{else}}
<span class="badge bg-danger-lt"><i class="ti ti-x"></i> Failed</span>
{{end}}
</td>
<td>{{index . "deployed_at"}}</td>
</tr>
{{else}}
<tr>
<td colspan="4" class="text-center text-secondary">No deployments yet.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(function() {
var details = document.getElementById('deploy-details');
var formHost = document.getElementById('form-host');
var formGroup = document.getElementById('form-group');
// Step 1: Target selection
document.querySelectorAll('input[name="deploy_target"]').forEach(function(el) {
el.addEventListener('change', function() {
details.style.display = 'block';
if (this.value === 'host') {
formHost.style.display = 'block';
formGroup.style.display = 'none';
} else {
formHost.style.display = 'none';
formGroup.style.display = 'block';
}
});
});
// Host auth toggle
document.querySelectorAll('.host-auth').forEach(function(el) {
el.addEventListener('change', function() {
document.getElementById('host-password-group').style.display = this.value === 'password' ? 'block' : 'none';
document.getElementById('host-authkey-group').style.display = this.value === 'key' ? 'block' : 'none';
});
});
// Group auth toggle
document.querySelectorAll('.grp-auth').forEach(function(el) {
el.addEventListener('change', function() {
document.getElementById('grp-password-group').style.display = this.value === 'password' ? 'block' : 'none';
document.getElementById('grp-authkey-group').style.display = this.value === 'key' ? 'block' : 'none';
});
});
})();
</script>
{{end}}
+65
View File
@@ -0,0 +1,65 @@
{{define "content"}}
<div class="row row-deck row-cards justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-lock-exclamation"></i> Password Change Required</h3>
</div>
<div class="card-body">
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">You must change your password</h4>
<div class="text-secondary">Your administrator has set an initial password for your account. Please choose a new personal password to continue.</div>
</div>
</div>
</div>
{{if .PasswordPolicy}}
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Password Requirements</h4>
<ul class="mb-0">
<li>Minimum <strong>{{.PasswordPolicy.MinLength}}</strong> characters</li>
{{if .PasswordPolicy.RequireUpper}}<li>At least one <strong>uppercase letter</strong> (A-Z)</li>{{end}}
{{if .PasswordPolicy.RequireLower}}<li>At least one <strong>lowercase letter</strong> (a-z)</li>{{end}}
{{if .PasswordPolicy.RequireDigit}}<li>At least one <strong>digit</strong> (0-9)</li>{{end}}
{{if .PasswordPolicy.RequireSpecial}}<li>At least one <strong>special character</strong> (!@#$...)</li>{{end}}
</ul>
</div>
</div>
</div>
{{end}}
<form action="/password/change" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label required">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="new_password" class="form-control" placeholder="New password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="mb-3">
<label class="form-label required">Confirm New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-lock-check"></i> Set New Password
</button>
</div>
</form>
<div class="text-center mt-3">
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
</div>
</div>
</div>
</div>
</div>
{{end}}
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Complete Registration - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<script>
(function() {
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
})();
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
{{if .Error}}
<!-- Error state (invalid/expired/used token) -->
<h2 class="h2 text-center mb-4">
<i class="ti ti-alert-triangle text-warning"></i> {{.Title}}
</h2>
<div class="alert alert-warning">
<i class="ti ti-alert-circle"></i> {{.Error}}
</div>
<div class="text-center mt-3">
<a href="/login" class="btn btn-primary">
<i class="ti ti-login"></i> Go to Login
</a>
</div>
{{else}}
<!-- Registration form -->
<h2 class="h2 text-center mb-4">
<i class="ti ti-user-check"></i> Complete Registration
</h2>
{{if .Flash}}
<div class="alert alert-{{.Flash.Type}}">
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
</div>
{{end}}
<p class="text-secondary text-center mb-3">
Welcome, <strong>{{.EditUser.Username}}</strong>! Please set your password to activate your account.
</p>
<form action="/invite/{{.Data}}" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label">Username</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
<input type="text" class="form-control" value="{{.EditUser.Username}}" disabled>
</div>
</div>
<div class="mb-3">
<label class="form-label required">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="new_password" class="form-control" placeholder="New password" required autofocus minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
{{if .PasswordPolicy}}
<small class="form-hint">
Min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}.
</small>
{{else}}
<small class="form-hint">Minimum 8 characters.</small>
{{end}}
</div>
<div class="mb-3">
<label class="form-label required">Confirm Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm password" required>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-user-check"></i> Activate Account
</button>
</div>
</form>
{{end}}
</div>
</div>
<div class="text-center text-secondary mt-3">
&copy; 2026 Keywarden | AGPLv3
</div>
</div>
</div>
<script src="/static/js/tabler.min.js"></script>
<script>
(function() {
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
var token = m ? decodeURIComponent(m[1]) : '';
document.querySelectorAll('form').forEach(function(form) {
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = token;
form.prepend(input);
}
});
})();
</script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-key"></i> SSH Keys</h3>
<div class="card-actions">
<a href="/keys/generate" class="btn btn-primary">
<i class="ti ti-plus"></i> Generate New Key
</a>
<a href="/keys/import" class="btn btn-outline-primary ms-2">
<i class="ti ti-upload"></i> Import Key
</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
{{if or (eq .User.Role "admin") (eq .User.Role "owner")}}
<th>Owner</th>
{{end}}
<th>Name</th>
<th>Type</th>
<th>Bits</th>
<th>Fingerprint</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{if or (eq $.User.Role "admin") (eq $.User.Role "owner")}}
{{/* Admin/Owner view: show all keys with owner info */}}
{{range .Data}}
<tr>
<td>
<span class="badge bg-{{if eq .OwnerUsername $.User.Username}}green-lt{{else}}blue-lt{{end}}">{{.OwnerUsername}}</span>
</td>
<td>
<div class="d-flex align-items-center">
<i class="ti ti-key me-2 text-primary"></i>
<strong>{{.Name}}</strong>
</div>
</td>
<td>
<span class="badge bg-{{if eq .KeyType "ed25519"}}green-lt{{else}}azure-lt{{end}}">{{.KeyType}}</span>
</td>
<td>{{.Bits}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>
<div class="btn-list flex-nowrap">
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
<i class="ti ti-eye"></i>
</a>
{{if eq .UserID $.User.ID}}
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
<i class="ti ti-download"></i>
</a>
{{end}}
<form method="POST" action="/keys/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this key?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="text-center text-secondary py-4">
<i class="ti ti-key-off" style="font-size: 2rem;"></i>
<p class="mt-2">No SSH keys found. Generate or import one to get started.</p>
</td>
</tr>
{{end}}
{{else}}
{{/* User view: show only own keys */}}
{{range .Keys}}
<tr>
<td>
<div class="d-flex align-items-center">
<i class="ti ti-key me-2 text-primary"></i>
<strong>{{.Name}}</strong>
</div>
</td>
<td>
<span class="badge bg-{{if eq .KeyType "ed25519"}}green-lt{{else}}azure-lt{{end}}">{{.KeyType}}</span>
</td>
<td>{{.Bits}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>
<div class="btn-list flex-nowrap">
<a href="/keys/{{.ID}}/view" class="btn btn-sm btn-icon btn-outline-primary" title="View Public Key">
<i class="ti ti-eye"></i>
</a>
<a href="/keys/{{.ID}}/download" class="btn btn-sm btn-icon btn-outline-secondary" title="Download Private Key">
<i class="ti ti-download"></i>
</a>
<form method="POST" action="/keys/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this key?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="text-center text-secondary py-4">
<i class="ti ti-key-off" style="font-size: 2rem;"></i>
<p class="mt-2">No SSH keys found. Generate or import one to get started.</p>
</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
+95
View File
@@ -0,0 +1,95 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-plus"></i> Generate New SSH Key</h3>
</div>
<div class="card-body">
<form action="/keys/generate" method="POST">
{{if .Users}}
<div class="mb-3">
<label class="form-label required">Generate for User</label>
<select name="target_user_id" class="form-select">
{{$currentUser := .User}}
{{range .Users}}
<option value="{{.ID}}" {{if eq .ID $currentUser.ID}}selected{{end}}>{{.Username}} ({{.Role}})</option>
{{end}}
</select>
<small class="form-hint">As admin you can generate SSH keys for any user</small>
</div>
{{end}}
<div class="mb-3">
<label class="form-label required">Key Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. production-server" required>
<small class="form-hint">A friendly name to identify this key</small>
</div>
<div class="mb-3">
<label class="form-label">Key Comment</label>
<input type="text" name="comment" class="form-control" placeholder="e.g. user@hostname">
<small class="form-hint">Comment appended to the public key (visible in authorized_keys and with <code>ssh-keygen -l</code>)</small>
</div>
<div class="mb-3">
<label class="form-label required">Key Type</label>
<div class="row g-3">
<div class="col-lg-4 col-md-4 col-sm-12">
<label class="form-selectgroup-item" style="margin:0; width:100%; height:100%;">
<input type="radio" name="key_type" value="ed25519" class="form-selectgroup-input" checked>
<span class="form-selectgroup-label d-flex flex-column align-items-center text-center p-3" style="width:100%; height:100%; border-radius:.5rem; cursor:pointer;">
<span class="mb-2" style="font-size:2rem; line-height:1;"><i class="ti ti-shield-check text-success"></i></span>
<span class="fw-bold mb-1">Ed25519</span>
<small class="text-secondary">Modern, fast, secure (recommended)</small>
<span class="badge bg-success-lt text-success mt-2">Recommended</span>
</span>
</label>
</div>
<div class="col-lg-4 col-md-4 col-sm-12">
<label class="form-selectgroup-item" style="margin:0; width:100%; height:100%;">
<input type="radio" name="key_type" value="ed448" class="form-selectgroup-input">
<span class="form-selectgroup-label d-flex flex-column align-items-center text-center p-3" style="width:100%; height:100%; border-radius:.5rem; cursor:pointer;">
<span class="mb-2" style="font-size:2rem; line-height:1;"><i class="ti ti-shield-lock text-azure"></i></span>
<span class="fw-bold mb-1">Ed448</span>
<small class="text-secondary">448-bit Edwards curve, higher security margin</small>
</span>
</label>
</div>
<div class="col-lg-4 col-md-4 col-sm-12">
<label class="form-selectgroup-item" style="margin:0; width:100%; height:100%;">
<input type="radio" name="key_type" value="rsa" class="form-selectgroup-input">
<span class="form-selectgroup-label d-flex flex-column align-items-center text-center p-3" style="width:100%; height:100%; border-radius:.5rem; cursor:pointer;">
<span class="mb-2" style="font-size:2rem; line-height:1;"><i class="ti ti-shield text-orange"></i></span>
<span class="fw-bold mb-1">RSA</span>
<small class="text-secondary">Legacy, wide compatibility</small>
</span>
</label>
</div>
</div>
</div>
<div class="mb-3" id="rsa-bits-group" style="display:none;">
<label class="form-label">RSA Key Size</label>
<select name="bits" class="form-select">
<option value="4096" selected>4096 bits (recommended)</option>
<option value="2048">2048 bits (legacy)</option>
</select>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-key"></i> Generate Key
</button>
<a href="/keys" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('input[name="key_type"]').forEach(function(el) {
el.addEventListener('change', function() {
document.getElementById('rsa-bits-group').style.display =
this.value === 'rsa' ? 'block' : 'none';
});
});
</script>
{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-lg-8 mx-auto">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-upload"></i> Import SSH Key</h3>
</div>
<div class="card-body">
<form action="/keys/import" method="POST">
{{if .Users}}
<div class="mb-3">
<label class="form-label required">Import for User</label>
<select name="target_user_id" class="form-select">
{{$currentUser := .User}}
{{range .Users}}
<option value="{{.ID}}" {{if eq .ID $currentUser.ID}}selected{{end}}>{{.Username}} ({{.Role}})</option>
{{end}}
</select>
<small class="form-hint">As admin you can import SSH keys for any user</small>
</div>
{{end}}
<div class="mb-3">
<label class="form-label required">Key Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. My Server Key" required>
</div>
<div class="mb-3">
<label class="form-label required">Private Key (PEM)</label>
<textarea name="private_key" class="form-control" rows="10" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----" required style="font-family: monospace; font-size: 0.85rem;"></textarea>
<small class="form-hint">Paste your private key in PEM format. The public key and fingerprint will be automatically extracted.</small>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-upload"></i> Import Key
</button>
<a href="/keys" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+669
View File
@@ -0,0 +1,669 @@
{{define "base"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>{{.Title}} - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<!-- Preload icon font to prevent re-decode lag on tab restore -->
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
<script>
(function() {
var theme = '{{with .User}}{{.Theme}}{{end}}' || 'auto';
var resolved = theme;
if (theme === 'auto') {
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
})();
</script>
<style>
/* Critical inline styles: prevent white flash between page navigations */
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #0F1829; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
.navbar-brand-image { height: 2rem; }
.keywarden-brand { font-weight: 700; font-size: 1.25rem; color: #206bc4; }
[data-bs-theme="dark"] .keywarden-brand { color: #4da3ff; }
/* Text selection colors */
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
/* Consistent spacing between Tabler icons and adjacent text */
i.ti { margin-right: 0.25em; }
.btn-icon > i.ti, .input-icon-addon > i.ti, .nav-link-icon > i.ti { margin-right: 0; }
/* ══ PAGE LAYOUT: full-width header on top, sidebar + content below ══ */
.page {
display: flex !important;
flex-direction: column !important;
min-height: 100vh;
}
.page-content-row {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Full-width top header ── */
header.navbar.keywarden-top-header {
background: #1D2B38 !important;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
z-index: 1030;
}
[data-bs-theme="light"] header.navbar.keywarden-top-header {
background: #1D2B38 !important;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
header.navbar.keywarden-top-header .nav-link { color: #c8d6e5 !important; }
header.navbar.keywarden-top-header .nav-link .text-secondary { color: #8fa8c8 !important; }
header.navbar.keywarden-top-header .fw-bold { color: #e8eef5; }
/* ── Header brand area (left side, aligned with sidebar) ── */
.keywarden-header-brand {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.5rem;
white-space: nowrap;
}
.keywarden-header-brand .keywarden-brand { color: #c8d6e5; font-size: 1.15rem; }
.keywarden-header-brand .keywarden-brand:hover { color: #fff; text-decoration: none; }
.keywarden-header-brand .keywarden-brand i.ti { color: #4da3ff; font-size: 1.3rem; }
/* ── Angular header buttons ── */
header.navbar.keywarden-top-header .btn-header-modern {
color: #c8d6e5;
background: rgba(255,255,255,0.06);
border: 2px solid rgba(255,255,255,0.18);
border-radius: 4px;
padding: 0.35rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
}
header.navbar.keywarden-top-header .btn-header-modern:hover {
color: #fff;
background: rgba(255,255,255,0.14);
border-color: rgba(255,255,255,0.32);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
header.navbar.keywarden-top-header .btn-header-modern.btn-icon {
width: 2.16875rem;
height: 2.16875rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
/* ── Sidebar (vertical, below header) ── */
.navbar-vertical {
background: #1D2B38 !important;
flex-shrink: 0;
overflow: hidden;
align-self: stretch;
display: flex;
flex-direction: column;
}
.navbar-vertical > .container-fluid {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.navbar-vertical .navbar-collapse {
flex: 1;
display: flex !important;
flex-direction: column;
overflow: hidden;
}
.navbar-vertical .navbar-nav {
flex: 1;
display: flex;
flex-direction: column;
}
[data-bs-theme="light"] .navbar-vertical { background: #1D2B38 !important; }
/* ── Page content area ── */
.page-wrapper {
flex: 1;
min-width: 0;
overflow-y: auto;
margin-left: 0 !important;
}
[data-bs-theme="dark"] .page-wrapper { background: #0F1829; }
[data-bs-theme="dark"] .page-body { background: #0F1829; }
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #0F1829 !important; }
.page-body { content-visibility: auto; contain-intrinsic-size: auto 500px; }
/* ── Narrower dashboard stat cards ── */
.stat-card-narrow { max-width: 220px; }
/* ── Desktop: sidebar always visible ── */
@media (min-width: 992px) {
.navbar-vertical {
width: 256px !important;
min-width: 256px !important;
position: relative !important;
top: auto !important;
bottom: auto !important;
left: auto !important;
}
.navbar-vertical .container-fluid {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.navbar-vertical .nav-link-title {
opacity: 1;
white-space: nowrap;
pointer-events: auto;
width: auto;
}
.navbar-vertical .nav-link {
justify-content: start;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.navbar-vertical .nav-link-icon {
margin-right: 0.5rem;
width: 24px;
min-width: 24px;
text-align: center;
}
.navbar-vertical .nav-category {
opacity: 1;
height: auto;
padding: 0.6rem 0.75rem 0.25rem;
}
.navbar-vertical .nav-item + .nav-category,
.navbar-vertical .nav-category + .nav-category {
margin-top: 0.4rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 0.65rem;
}
.navbar-vertical .nav-category:first-child {
padding-top: 0;
}
/* Hide mobile burger button on desktop */
.mobile-menu-toggle {
display: none !important;
}
.page-wrapper {
padding-left: 0.75rem;
}
}
/* ── Mobile: off-canvas sidebar ── */
@media (max-width: 991.98px) {
aside.navbar-vertical {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px !important;
max-width: 85vw;
z-index: 1050;
transform: translateX(-100%);
transition: transform 0.3s ease;
overflow-y: auto;
padding-top: 3.5rem;
}
aside.navbar-vertical.mobile-open {
transform: translateX(0);
}
aside.navbar-vertical .navbar-collapse {
display: flex !important;
flex-direction: column;
padding: 0.5rem 0;
}
aside.navbar-vertical .container-fluid {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
aside.navbar-vertical .nav-link-title {
opacity: 1;
pointer-events: auto;
width: auto;
}
aside.navbar-vertical .nav-link {
justify-content: start;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
aside.navbar-vertical .nav-link-icon {
margin-right: 0.5rem;
}
aside.navbar-vertical .nav-category {
opacity: 1;
height: auto;
padding: 0.6rem 0.75rem 0.25rem;
}
/* Backdrop overlay */
.mobile-sidebar-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1040;
}
.mobile-sidebar-backdrop.show {
display: block;
}
/* Mobile burger button */
.mobile-menu-toggle {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
color: #8fa8c8;
border-radius: 6px;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.25s ease;
padding: 0;
}
.mobile-menu-toggle:hover {
background: rgba(255,255,255,0.16);
color: #fff;
}
.mobile-menu-toggle .ti {
font-size: 1rem;
margin: 0;
}
}
/* ── Sidebar category headers ── */
.nav-category {
display: block;
padding: 0.6rem 0.75rem 0.25rem;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(138, 166, 200, 0.55);
white-space: nowrap;
user-select: none;
pointer-events: none;
}
.nav-category:first-child {
padding-top: 0;
}
/* Show a subtle divider line above categories (except the first) */
.nav-category + .nav-category,
.nav-item + .nav-category {
margin-top: 0.4rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 0.65rem;
}
/* ── Avatar in dropdown ── */
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
</style>
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
</head>
<body class="layout-fluid">
<div class="page">
<!-- ═══ FULL-WIDTH TOP HEADER ═══ -->
<header class="navbar d-print-none keywarden-top-header" data-bs-theme="dark">
<div class="container-fluid px-3">
<div class="d-flex align-items-center w-100">
<!-- Logo + Brand (left side, aligned with sidebar) -->
<div class="keywarden-header-brand">
<button class="mobile-menu-toggle d-lg-none" onclick="toggleMobileMenu()" title="Menü" id="mobile-menu-btn">
<i class="ti ti-menu-2" id="mobile-menu-icon"></i>
</button>
<a href="/dashboard" class="keywarden-brand text-decoration-none d-flex align-items-center">
<i class="ti ti-key"></i> {{appName}}
</a>
</div>
<!-- Spacer -->
<div class="flex-grow-1"></div>
<!-- Repository Link -->
<div class="nav-item d-none d-md-flex me-2">
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
<i class="ti ti-brand-git"></i> Repository
</a>
</div>
<!-- Documentation -->
<div class="nav-item d-none d-md-flex me-2">
<a href="https://git.techniverse.net/scriptos/keywarden/src/branch/master/docs" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Documentation">
<i class="ti ti-book"></i> Docs
</a>
</div>
<!-- Theme Toggle -->
<div class="nav-item d-flex me-2">
<button id="theme-toggle" class="btn btn-header-modern btn-sm btn-icon" title="Toggle theme" onclick="toggleTheme()">
<i class="ti ti-sun" id="theme-icon"></i>
</button>
</div>
<!-- User Badge -->
{{with .User}}
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<span class="avatar avatar-sm rounded-circle bg-primary-lt">
{{if .AvatarBase64}}
<img src="/avatar/{{.ID}}" class="avatar-img" alt="Avatar">
{{else}}
<i class="ti ti-user" style="font-size: 1.1rem;"></i>
{{end}}
</span>
<div class="d-none d-xl-block ps-2">
<div class="fw-bold">{{.Username}}</div>
<div class="mt-1 small text-secondary">{{if eq .Role "owner"}}Owner{{else if eq .Role "admin"}}Administrator{{else}}User{{end}}</div>
</div>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a class="dropdown-item text-danger" href="/logout">
<i class="ti ti-logout"></i> Logout
</a>
</div>
</div>
{{end}}
</div>
</div>
</header>
<!-- ═══ CONTENT ROW: sidebar + main ═══ -->
<div class="page-content-row">
<!-- Mobile sidebar backdrop -->
<div class="mobile-sidebar-backdrop" id="mobile-sidebar-backdrop" onclick="closeMobileMenu()"></div>
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark" id="keywarden-sidebar">
<div class="container-fluid">
<div class="collapse navbar-collapse" id="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<!-- ── Overview ── -->
<li class="nav-category">Overview</li>
<li class="nav-item{{if eq .Active "dashboard"}} active{{end}}">
<a class="nav-link" href="/dashboard">
<span class="nav-link-icon"><i class="ti ti-dashboard"></i></span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<!-- ── Infrastructure (Admin/Owner only) ── -->
{{with .User}}
{{if or (eq .Role "admin") (eq .Role "owner")}}
<li class="nav-category">Infrastructure</li>
<li class="nav-item{{if eq $.Active "servers"}} active{{end}}">
<a class="nav-link" href="/servers">
<span class="nav-link-icon"><i class="ti ti-server"></i></span>
<span class="nav-link-title">Hosts</span>
</a>
</li>
<li class="nav-item{{if eq $.Active "groups"}} active{{end}}">
<a class="nav-link" href="/groups">
<span class="nav-link-icon"><i class="ti ti-folders"></i></span>
<span class="nav-link-title">Groups</span>
</a>
</li>
{{end}}
{{end}}
<!-- ── Key Management ── -->
<li class="nav-category">Key Management</li>
<li class="nav-item{{if eq .Active "keys"}} active{{end}}">
<a class="nav-link" href="/keys">
<span class="nav-link-icon"><i class="ti ti-key"></i></span>
<span class="nav-link-title">SSH Keys</span>
</a>
</li>
<!-- My Access (visible to all users) -->
<li class="nav-item{{if eq .Active "my_access"}} active{{end}}">
<a class="nav-link" href="/my/access">
<span class="nav-link-icon"><i class="ti ti-shield-check"></i></span>
<span class="nav-link-title">My Access</span>
</a>
</li>
{{with .User}}
{{if or (eq .Role "admin") (eq .Role "owner")}}
<li class="nav-item{{if eq $.Active "deploy"}} active{{end}}">
<a class="nav-link" href="/deploy">
<span class="nav-link-icon"><i class="ti ti-send"></i></span>
<span class="nav-link-title">Deploy Keys</span>
</a>
</li>
{{end}}
{{end}}
<!-- ── Operations (Admin/Owner only) ── -->
{{with .User}}
{{if or (eq .Role "admin") (eq .Role "owner")}}
<li class="nav-category">Operations</li>
<li class="nav-item{{if eq $.Active "cron"}} active{{end}}">
<a class="nav-link" href="/cron">
<span class="nav-link-icon"><i class="ti ti-clock"></i></span>
<span class="nav-link-title">Temporary Access</span>
</a>
</li>
{{end}}
{{end}}
<li class="nav-item{{if eq .Active "audit"}} active{{end}}">
<a class="nav-link" href="/audit">
<span class="nav-link-icon"><i class="ti ti-list-details"></i></span>
<span class="nav-link-title">Audit Log</span>
</a>
</li>
<!-- ── Administration ── -->
<li class="nav-category">Administration</li>
{{with .User}}
{{if or (eq .Role "admin") (eq .Role "owner")}}
<li class="nav-item{{if eq $.Active "users"}} active{{end}}">
<a class="nav-link" href="/users">
<span class="nav-link-icon"><i class="ti ti-users"></i></span>
<span class="nav-link-title">Users</span>
</a>
</li>
<li class="nav-item{{if eq $.Active "assignments"}} active{{end}}">
<a class="nav-link" href="/assignments">
<span class="nav-link-icon"><i class="ti ti-shield-lock"></i></span>
<span class="nav-link-title">Access Assignments</span>
</a>
</li>
{{end}}
{{end}}
<li class="nav-item{{if eq .Active "settings"}} active{{end}}">
<a class="nav-link" href="/settings">
<span class="nav-link-icon"><i class="ti ti-settings"></i></span>
<span class="nav-link-title">Settings</span>
</a>
</li>
{{with .User}}
{{if eq .Role "owner"}}
<li class="nav-item{{if eq $.Active "admin_settings"}} active{{end}}">
<a class="nav-link" href="/admin/settings">
<span class="nav-link-icon"><i class="ti ti-shield-cog"></i></span>
<span class="nav-link-title">Admin Settings</span>
</a>
</li>
{{end}}
{{end}}
<!-- ── System (Admin/Owner only) ── -->
{{with .User}}
{{if or (eq .Role "admin") (eq .Role "owner")}}
<li class="nav-category">System</li>
<li class="nav-item{{if eq $.Active "system_info"}} active{{end}}">
<a class="nav-link" href="/system">
<span class="nav-link-icon"><i class="ti ti-info-circle"></i></span>
<span class="nav-link-title">System Information</span>
</a>
</li>
{{end}}
{{end}}
</ul>
</div>
</div>
</aside>
<!-- Main content -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="page-pretitle">Keywarden Centralized SSH Key Management and Deployment</div>
<h2 class="page-title">{{.Title}}</h2>
</div>
</div>
<div class="page-body">
<div class="container-xl">
{{if .Flash}}
<div class="alert alert-{{.Flash.Type}} alert-dismissible" role="alert">
<div class="d-flex">
<div>
{{if eq .Flash.Type "success"}}<i class="ti ti-check icon alert-icon"></i>{{end}}
{{if eq .Flash.Type "danger"}}<i class="ti ti-alert-circle icon alert-icon"></i>{{end}}
{{if eq .Flash.Type "warning"}}<i class="ti ti-alert-triangle icon alert-icon"></i>{{end}}
</div>
<div>{{.Flash.Message}}</div>
</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
</div>
{{end}}
{{template "content" .}}
</div>
</div>
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center">
<div class="col-12">
<span class="text-secondary">&copy; 2026 Keywarden Centralized SSH Key Management and Deployment | AGPLv3</span>
</div>
</div>
</div>
</footer>
</div>
</div><!-- /page-content-row -->
</div><!-- /page -->
<!-- Tabler JS (self-hosted) -->
<script src="/static/js/tabler.min.js"></script>
<script>
// --- Theme Toggle ---
function getResolvedTheme() {
var stored = document.documentElement.getAttribute('data-bs-theme');
return stored || 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
document.documentElement.style.colorScheme = theme;
updateThemeIcon(theme);
}
function updateThemeIcon(theme) {
var icon = document.getElementById('theme-icon');
if (!icon) return;
icon.className = theme === 'dark' ? 'ti ti-moon' : 'ti ti-sun';
}
function toggleTheme() {
var current = getResolvedTheme();
var next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
// Persist choice via API (fire-and-forget)
var csrf = (document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/) || [])[1] || '';
fetch('/settings/theme', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'theme=' + encodeURIComponent(next) + '&_csrf=' + encodeURIComponent(csrf)
});
}
// Set initial icon on page load
document.addEventListener('DOMContentLoaded', function() {
updateThemeIcon(getResolvedTheme());
});
// --- Mobile Sidebar Toggle ---
function toggleMobileMenu() {
var sidebar = document.getElementById('keywarden-sidebar');
var backdrop = document.getElementById('mobile-sidebar-backdrop');
var icon = document.getElementById('mobile-menu-icon');
if (!sidebar) return;
var isOpen = sidebar.classList.toggle('mobile-open');
if (backdrop) backdrop.classList.toggle('show', isOpen);
if (icon) icon.className = isOpen ? 'ti ti-x' : 'ti ti-menu-2';
}
function closeMobileMenu() {
var sidebar = document.getElementById('keywarden-sidebar');
var backdrop = document.getElementById('mobile-sidebar-backdrop');
var icon = document.getElementById('mobile-menu-icon');
if (sidebar) sidebar.classList.remove('mobile-open');
if (backdrop) backdrop.classList.remove('show');
if (icon) icon.className = 'ti ti-menu-2';
}
function copyToClipboard(elementId, btn) {
var el = document.getElementById(elementId);
var text = el.value || el.textContent;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function() {
showCopyFeedback(btn);
});
} else {
// Fallback for non-HTTPS: use temporary textarea (password inputs block select/copy)
var tmp = document.createElement('textarea');
tmp.value = text;
tmp.style.position = 'fixed';
tmp.style.opacity = '0';
document.body.appendChild(tmp);
tmp.focus();
tmp.select();
tmp.setSelectionRange(0, 99999);
document.execCommand('copy');
document.body.removeChild(tmp);
showCopyFeedback(btn);
}
}
function showCopyFeedback(btn) {
var orig = btn.innerHTML;
btn.innerHTML = '<i class="ti ti-check"></i>';
btn.classList.add('btn-success');
btn.classList.remove('btn-outline-primary');
setTimeout(function() {
btn.innerHTML = orig;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-primary');
}, 2000);
}
// --- CSRF Protection ---
// Reads the _csrf cookie and injects a hidden field into every POST form.
// Also provides a helper for fetch/AJAX calls.
(function() {
function getCsrfToken() {
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
return m ? decodeURIComponent(m[1]) : '';
}
// Inject hidden _csrf field into all POST forms
document.querySelectorAll('form').forEach(function(form) {
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = getCsrfToken();
form.prepend(input);
}
});
// Expose globally for fetch/AJAX calls
window._csrfToken = getCsrfToken;
})();
</script>
</body>
</html>
{{end}}
+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Login - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<!-- Preload icon font to prevent re-decode lag on tab restore -->
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
<script>
(function() {
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
})();
</script>
<style>
/* Critical inline styles: prevent white flash between page navigations */
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
/* Text selection colors */
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
</style>
<!-- Tabler CSS (self-hosted to prevent FOUC) -->
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
{{if .MFAPending}}
<!-- MFA Verification Step -->
<h2 class="h2 text-center mb-4">
<i class="ti ti-shield-lock"></i> MFA Verification
</h2>
{{if .Error}}
<div class="alert alert-danger">
<i class="ti ti-alert-circle"></i> {{.Error}}
</div>
{{end}}
<p class="text-secondary text-center mb-3">Enter the 6-digit code from your authenticator app.</p>
<form action="/login/mfa" method="post" autocomplete="off">
<input type="hidden" name="mfa_token" value="{{.MFAToken}}">
<div class="mb-3">
<label class="form-label">MFA Code</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-shield-lock"></i></span>
<input type="text" name="mfa_code" class="form-control" placeholder="000000"
required pattern="[0-9]{6}" maxlength="6" autofocus
style="font-size: 1.5rem; letter-spacing: 0.5rem; text-align: center;">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-shield-check"></i> Verify
</button>
</div>
</form>
<div class="text-center mt-3">
<a href="/login" class="text-secondary"><i class="ti ti-arrow-left"></i> Back to login</a>
</div>
{{else}}
<!-- Normal Login -->
<h2 class="h2 text-center mb-4">Login</h2>
{{if .Error}}
<div class="alert alert-danger">
<i class="ti ti-alert-circle"></i> {{.Error}}
</div>
{{end}}
<form action="/login" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label">Username</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
<input type="text" name="username" class="form-control" placeholder="Username" required autofocus>
</div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="password" class="form-control" placeholder="Password" required>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-login"></i> Sign in
</button>
</div>
</form>
{{end}}
</div>
</div>
<div class="text-center text-secondary mt-3">
&copy; 2026 Keywarden | AGPLv3
</div>
</div>
</div>
<script src="/static/js/tabler.min.js"></script>
<script>
// --- CSRF Protection ---
(function() {
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
var token = m ? decodeURIComponent(m[1]) : '';
document.querySelectorAll('form').forEach(function(form) {
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = token;
form.prepend(input);
}
});
})();
</script>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>MFA Setup Required - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<script>
(function() {
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
})();
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">
<i class="ti ti-shield-check"></i> MFA Setup Required
</h2>
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Two-Factor Authentication is required</h4>
<div class="text-secondary">Your administrator requires all users to set up two-factor authentication. Please configure MFA to continue.</div>
</div>
</div>
</div>
{{if .Flash}}
<div class="alert alert-{{.Flash.Type}}">
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
</div>
{{end}}
<div class="mb-4">
<h4>Step 1: Scan QR Code</h4>
<p class="text-secondary">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<div class="text-center my-4">
<div id="qrcode" class="d-inline-block p-3 bg-white border rounded"></div>
</div>
<div class="text-center">
<p class="text-secondary mb-1">Or enter this secret manually:</p>
<code class="fs-4 user-select-all">{{.MFASecret}}</code>
</div>
</div>
<hr>
<div>
<h4>Step 2: Verify Code</h4>
<p class="text-secondary">
Enter the 6-digit code from your authenticator app to confirm setup.
</p>
<form action="/mfa/setup" method="post" autocomplete="off">
<input type="hidden" name="mfa_secret" value="{{.MFASecret}}">
<div class="mb-3">
<label class="form-label required">Verification Code</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-shield-lock"></i></span>
<input type="text" name="mfa_code" class="form-control" placeholder="000000"
required pattern="[0-9]{6}" maxlength="6" autocomplete="off" autofocus
style="font-size: 1.5rem; letter-spacing: 0.5rem; text-align: center;">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-shield-check"></i> Enable MFA
</button>
</div>
</form>
</div>
<div class="text-center mt-3">
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
new QRCode(document.getElementById("qrcode"), {
text: "{{.MFAUri}}",
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
// --- CSRF Protection ---
(function() {
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
var token = m ? decodeURIComponent(m[1]) : '';
document.querySelectorAll('form').forEach(function(form) {
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = token;
form.prepend(input);
}
});
})();
</script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
{{define "content"}}
<div class="row row-deck row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-shield-check"></i> Setup Two-Factor Authentication</h3>
</div>
<div class="card-body">
{{if .MFARequired}}
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">MFA is required</h4>
<div class="text-secondary">Your administrator requires all users to set up two-factor authentication. Please configure MFA to continue using the application.</div>
</div>
</div>
</div>
{{end}}
<div class="mb-4">
<h4>Step 1: Scan QR Code</h4>
<p class="text-secondary">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<div class="text-center my-4">
<div id="qrcode" class="d-inline-block p-3 bg-white border rounded"></div>
</div>
<div class="text-center">
<p class="text-secondary mb-1">Or enter this secret manually:</p>
<code class="fs-4 user-select-all">{{.MFASecret}}</code>
</div>
</div>
<hr>
<div>
<h4>Step 2: Verify Code</h4>
<p class="text-secondary">
Enter the 6-digit code from your authenticator app to confirm setup.
</p>
<form action="/settings/mfa/setup" method="post">
<input type="hidden" name="mfa_secret" value="{{.MFASecret}}">
<div class="mb-3">
<label class="form-label required">Verification Code</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-shield-lock"></i></span>
<input type="text" name="mfa_code" class="form-control" placeholder="000000"
required pattern="[0-9]{6}" maxlength="6" autocomplete="off" autofocus
style="font-size: 1.5rem; letter-spacing: 0.5rem; text-align: center;">
</div>
</div>
<div class="form-footer">
{{if not .MFARequired}}
<a href="/settings" class="btn btn-outline-secondary me-2">
<i class="ti ti-arrow-left"></i> Cancel
</a>
{{end}}
<button type="submit" class="btn btn-primary">
<i class="ti ti-shield-check"></i> Enable MFA
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- QR Code Generation via JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
new QRCode(document.getElementById("qrcode"), {
text: "{{.MFAUri}}",
width: 200,
height: 200,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.M
});
</script>
{{end}}
+63
View File
@@ -0,0 +1,63 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-folders"></i> Groups</h3>
<div class="card-actions">
<a href="/groups/add" class="btn btn-primary">
<i class="ti ti-plus"></i> Create Group
</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Servers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Groups}}
<tr>
<td>
<div class="d-flex align-items-center">
<i class="ti ti-folders me-2 text-primary"></i>
<strong>{{.Name}}</strong>
</div>
</td>
<td>{{.Description}}</td>
<td>
<span class="badge bg-blue-lt">{{.ServerCount}} server{{if ne .ServerCount 1}}s{{end}}</span>
</td>
<td>
<div class="btn-list flex-nowrap">
<a href="/groups/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit / Manage Servers">
<i class="ti ti-edit"></i>
</a>
<form method="POST" action="/groups/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this group? The hosts themselves will not be deleted.')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete Group">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="4" class="text-center text-secondary py-4">
<i class="ti ti-folders-off" style="font-size: 2rem;"></i>
<p class="mt-2">No groups yet. Create one to organize your hosts.</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
+29
View File
@@ -0,0 +1,29 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-plus"></i> Create Group</h3>
</div>
<div class="card-body">
<form action="/groups/add" method="POST">
<div class="mb-3">
<label class="form-label required">Group Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. Production Servers" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Optional description..."></textarea>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-folders"></i> Create Group
</button>
<a href="/groups" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+110
View File
@@ -0,0 +1,110 @@
{{define "content"}}
<div class="row row-cards">
<!-- Edit Group Info -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-edit"></i> Edit Group</h3>
</div>
<div class="card-body">
{{$group := .Group}}
<form action="/groups/{{$group.ID}}/edit" method="POST">
<div class="mb-3">
<label class="form-label required">Group Name</label>
<input type="text" name="name" class="form-control" value="{{$group.Name}}" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2">{{$group.Description}}</textarea>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Changes
</button>
<a href="/groups" class="btn btn-outline-secondary ms-2">Back</a>
</div>
</form>
</div>
</div>
</div>
<!-- Add Server to Group -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-server"></i> Add Host to Group</h3>
</div>
<div class="card-body">
<form action="/groups/{{$group.ID}}/add-server" method="POST">
<div class="mb-3">
<label class="form-label required">Select Host</label>
<select name="server_id" class="form-select" required>
<option value="">Choose a host...</option>
{{range .AllServers}}
<option value="{{.ID}}">{{.Name}} ({{.Hostname}}:{{.Port}})</option>
{{end}}
</select>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-success">
<i class="ti ti-plus"></i> Add Host
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Current Members -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-list"></i> Hosts in this Group</h3>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Port</th>
<th>User</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .GroupServers}}
<tr>
<td>
<div class="d-flex align-items-center">
<i class="ti ti-server me-2 text-primary"></i>
<strong>{{.Name}}</strong>
</div>
</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
<td>{{.Username}}</td>
<td>
<form method="POST" action="/groups/{{$group.ID}}/remove-server" class="d-inline" onsubmit="return confirm('Remove this host from the group?')">
<input type="hidden" name="server_id" value="{{.ID}}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Remove from Group">
<i class="ti ti-x"></i> Remove
</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="5" class="text-center text-secondary py-4">
<i class="ti ti-server-off" style="font-size: 2rem;"></i>
<p class="mt-2">No hosts in this group yet. Add hosts above.</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
+126
View File
@@ -0,0 +1,126 @@
{{define "content"}}
<div class="row row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-server"></i> Hosts</h3>
<div class="card-actions">
<a href="/servers/add" class="btn btn-primary">
<i class="ti ti-plus"></i> Add Host
</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Port</th>
<th>User</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Servers}}
<tr>
<td>
<div class="d-flex align-items-center">
<i class="ti ti-server me-2 text-primary"></i>
<strong>{{.Name}}</strong>
</div>
</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
<td>{{.Username}}</td>
<td>{{.Description}}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-sm btn-icon btn-outline-info test-reach-btn" title="Test Reachability (TCP)" data-server-id="{{.ID}}">
<i class="ti ti-plug"></i>
</button>
<button class="btn btn-sm btn-icon btn-outline-success test-auth-btn" title="Test SSH Login" data-server-id="{{.ID}}">
<i class="ti ti-key"></i>
</button>
<a href="/servers/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
<i class="ti ti-edit"></i>
</a>
<form method="POST" action="/servers/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this host?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="text-center text-secondary py-4">
<i class="ti ti-server-off" style="font-size: 2rem;"></i>
<p class="mt-2">No hosts configured. Add one to start deploying keys.</p>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function testServer(btn, url, origClass, origTitle) {
var serverId = btn.getAttribute('data-server-id');
var origHTML = btn.innerHTML;
btn.innerHTML = '<i class="ti ti-loader ti-spin"></i>';
btn.disabled = true;
var form = new FormData();
form.append('server_id', serverId);
// Add CSRF token
var csrfMatch = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
if (csrfMatch) form.append('_csrf', decodeURIComponent(csrfMatch[1]));
fetch(url, { method: 'POST', body: form })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
btn.classList.remove(origClass);
btn.classList.add('btn-success');
btn.innerHTML = '<i class="ti ti-check"></i>';
} else {
btn.classList.remove(origClass);
btn.classList.add('btn-outline-danger');
btn.innerHTML = '<i class="ti ti-x"></i>';
}
btn.title = data.message;
setTimeout(function() {
btn.innerHTML = origHTML;
btn.className = btn.className.replace(/btn-success|btn-outline-danger|btn-info/g, '');
btn.classList.add('btn', 'btn-sm', 'btn-icon', origClass);
btn.disabled = false;
btn.title = origTitle;
}, 4000);
})
.catch(function() {
btn.innerHTML = origHTML;
btn.disabled = false;
});
}
// Reachability test (TCP port check)
document.querySelectorAll('.test-reach-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
testServer(this, '/servers/test', 'btn-outline-info', 'Test Reachability (TCP)');
});
});
// SSH Auth test (actual SSH login with first key)
document.querySelectorAll('.test-auth-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
testServer(this, '/servers/test-auth', 'btn-outline-success', 'Test SSH Login');
});
});
</script>
{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-plus"></i> Add Host</h3>
</div>
<div class="card-body">
<form action="/servers/add" method="POST">
<div class="mb-3">
<label class="form-label required">Server Name</label>
<input type="text" name="name" class="form-control" placeholder="e.g. Web Server 01" required>
</div>
<div class="row mb-3">
<div class="col-8">
<label class="form-label required">Hostname / IP</label>
<input type="text" name="hostname" class="form-control" placeholder="e.g. 192.168.1.100 or server.example.com" required>
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="22" min="1" max="65535">
</div>
</div>
<div class="mb-3">
<label class="form-label required">SSH Username</label>
<input type="text" name="username" class="form-control" placeholder="e.g. root" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Optional description..."></textarea>
</div>
<div class="mb-3">
<label class="form-label">Groups</label>
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
{{range .Data}}
<label class="form-selectgroup-item flex-fill">
<input type="checkbox" name="group_ids" value="{{.ID}}" class="form-selectgroup-input">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3">
<span class="form-selectgroup-check"></span>
</div>
<div>
<strong>{{.Name}}</strong>
{{if .Description}}<br><small class="text-secondary">{{.Description}}</small>{{end}}
</div>
</div>
</label>
{{else}}
<div class="text-secondary">
<small>No groups available. <a href="/groups/add">Create a group</a> first.</small>
</div>
{{end}}
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-server"></i> Add Host
</button>
<a href="/servers" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+69
View File
@@ -0,0 +1,69 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-edit"></i> Edit Host</h3>
</div>
<div class="card-body">
{{$server := .Server}}
<form action="/servers/{{$server.ID}}/edit" method="POST">
<div class="mb-3">
<label class="form-label required">Server Name</label>
<input type="text" name="name" class="form-control" value="{{$server.Name}}" required>
</div>
<div class="row mb-3">
<div class="col-8">
<label class="form-label required">Hostname / IP</label>
<input type="text" name="hostname" class="form-control" value="{{$server.Hostname}}" required>
</div>
<div class="col-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" value="{{$server.Port}}" min="1" max="65535">
</div>
</div>
<div class="mb-3">
<label class="form-label required">SSH Username</label>
<input type="text" name="username" class="form-control" value="{{$server.Username}}" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2">{{$server.Description}}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Groups</label>
<div class="form-selectgroup form-selectgroup-boxes d-flex flex-column">
{{range .Data}}
<label class="form-selectgroup-item flex-fill">
<input type="checkbox" name="group_ids" value="{{.ID}}" class="form-selectgroup-input"
{{if .Selected}}checked{{end}}
>
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3">
<span class="form-selectgroup-check"></span>
</div>
<div>
<strong>{{.Name}}</strong>
{{if .Description}}<br><small class="text-secondary">{{.Description}}</small>{{end}}
</div>
</div>
</label>
{{else}}
<div class="text-secondary">
<small>No groups available. <a href="/groups/add">Create a group</a> first.</small>
</div>
{{end}}
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Changes
</button>
<a href="/servers" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+215
View File
@@ -0,0 +1,215 @@
{{define "content"}}
<div class="row row-deck row-cards">
<!-- Theme Settings -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-palette"></i> Appearance</h3>
</div>
<div class="card-body">
<form action="/settings/theme" method="post">
<div class="row align-items-end">
<div class="col-auto">
<label class="form-label">Theme</label>
<select name="theme" class="form-select" style="width: 250px;">
<option value="auto" {{if or (not .User) (eq .User.Theme "") (eq .User.Theme "auto")}}selected{{end}}>Automatic (System)</option>
<option value="light" {{if and .User (eq .User.Theme "light")}}selected{{end}}>Light</option>
<option value="dark" {{if and .User (eq .User.Theme "dark")}}selected{{end}}>Dark</option>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Profile Picture -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-camera"></i> Profile Picture</h3>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="avatar avatar-xl rounded-circle bg-primary-lt" id="avatar-preview-container">
{{with .User}}
{{if .AvatarBase64}}
<img src="/avatar/{{.ID}}" id="avatar-preview" style="width:100%;height:100%;object-fit:cover;border-radius:50%;" alt="Avatar">
{{else}}
<i class="ti ti-user" style="font-size: 2.5rem;" id="avatar-placeholder"></i>
{{end}}
{{end}}
</span>
</div>
<div class="col">
<form action="/settings/avatar" method="post" enctype="multipart/form-data">
<div class="mb-2">
<input type="file" name="avatar" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" onchange="previewAvatar(this)">
<small class="form-hint">Max. 2 MB. PNG, JPG, GIF or WebP.</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="ti ti-upload"></i> Upload
</button>
</div>
</form>
{{with .User}}
{{if .AvatarBase64}}
<form action="/settings/avatar" method="post" class="mt-2">
<input type="hidden" name="remove_avatar" value="1">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="ti ti-trash"></i> Remove Picture
</button>
</form>
{{end}}
{{end}}
</div>
</div>
</div>
</div>
</div>
<!-- Personal Settings -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-lock"></i> Change Password</h3>
</div>
<div class="card-body">
<form action="/settings" method="post">
<div class="mb-3">
<label class="form-label required">Current Password</label>
<input type="password" name="current_password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label required">New Password</label>
<input type="password" name="new_password" class="form-control" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
<div class="mb-3">
<label class="form-label required">Confirm New Password</label>
<input type="password" name="confirm_password" class="form-control" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
{{if .PasswordPolicy}}
<div class="mb-3">
<small class="form-hint">
Password requirements: min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}.
</small>
</div>
{{end}}
<div class="form-footer">
<button type="submit" class="btn btn-primary">
<i class="ti ti-lock"></i> Change Password
</button>
</div>
</form>
</div>
</div>
</div>
<!-- MFA Settings -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-shield-check"></i> Two-Factor Authentication (MFA)</h3>
</div>
<div class="card-body">
{{with .User}}
{{if .MFAEnabled}}
<div class="alert alert-success">
<div class="d-flex">
<div><i class="ti ti-shield-check icon alert-icon"></i></div>
<div>
<h4 class="alert-title">MFA is enabled</h4>
<div class="text-secondary">Your account is protected with two-factor authentication.</div>
</div>
</div>
</div>
{{if not $.MFARequired}}
<form action="/settings/mfa/disable" method="post" onsubmit="return confirm('Are you sure you want to disable MFA? This will reduce your account security.')">
<button type="submit" class="btn btn-outline-danger">
<i class="ti ti-shield-off"></i> Disable MFA
</button>
</form>
{{else}}
<div class="text-secondary">
<i class="ti ti-info-circle"></i> MFA is enforced by your administrator and cannot be disabled.
</div>
{{end}}
{{else}}
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">MFA is not enabled</h4>
<div class="text-secondary">Add an extra layer of security to your account.{{if $.MFARequired}} <strong>MFA is required by your administrator.</strong>{{end}}</div>
</div>
</div>
</div>
<a href="/settings/mfa/setup" class="btn btn-primary">
<i class="ti ti-shield-check"></i> Enable MFA
</a>
{{end}}
{{end}}
</div>
</div>
</div>
<!-- Email Notifications -->
{{if .EmailEnabled}}
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-mail"></i> Email Notifications</h3>
</div>
<div class="card-body">
{{with .User}}
<p class="text-secondary mb-3">
Receive email notifications for certain events. Notifications are sent to <strong>{{.Email}}</strong>.
</p>
<form action="/settings/email/notify" method="post">
<div class="mb-3">
<label class="form-check form-switch">
<input type="hidden" name="email_notify_login" value="0">
<input class="form-check-input" type="checkbox" name="email_notify_login" value="1" {{if .EmailNotifyLogin}}checked{{end}} onchange="this.form.submit()">
<span class="form-check-label">Login notification</span>
<span class="form-check-description">Send an email every time someone logs into your account.</span>
</label>
</div>
</form>
{{end}}
</div>
</div>
</div>
{{end}}
</div>
<script>
function previewAvatar(input) {
if (!input.files || !input.files[0]) return;
var reader = new FileReader();
reader.onload = function(e) {
var container = document.getElementById('avatar-preview-container');
var existing = document.getElementById('avatar-preview');
var placeholder = document.getElementById('avatar-placeholder');
if (placeholder) placeholder.style.display = 'none';
if (existing) {
existing.src = e.target.result;
} else {
var img = document.createElement('img');
img.id = 'avatar-preview';
img.src = e.target.result;
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%;';
img.alt = 'Avatar';
container.appendChild(img);
}
};
reader.readAsDataURL(input.files[0]);
}
</script>
{{end}}
+76
View File
@@ -0,0 +1,76 @@
{{define "content"}}
<div class="row row-deck row-cards">
<!-- System Information -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-info-circle"></i> System Information</h3>
</div>
<div class="card-body">
{{with .SystemInfo}}
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Runtime Environment</div>
<div class="datagrid-content">
{{if eq .Runtime "Docker"}}
<span class="badge bg-blue-lt"><i class="ti ti-brand-docker"></i> Docker</span>
{{else}}
<span class="badge bg-cyan-lt">Native</span>
{{end}}
</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Architecture</div>
<div class="datagrid-content"><span class="badge bg-purple-lt">{{.Arch}}</span></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Operating System</div>
<div class="datagrid-content">{{.OS}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Hostname</div>
<div class="datagrid-content"><code>{{.Hostname}}</code></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Memory (Allocated)</div>
<div class="datagrid-content">{{.MemAlloc}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Memory (System)</div>
<div class="datagrid-content">{{.MemSys}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">CPU Cores</div>
<div class="datagrid-content">{{.NumCPU}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Goroutines</div>
<div class="datagrid-content">{{.NumGoroutine}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Go Version</div>
<div class="datagrid-content">{{.GoVersion}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Uptime</div>
<div class="datagrid-content">{{.Uptime}}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Encryption</div>
<div class="datagrid-content"><span class="badge bg-green-lt">AES-256-GCM</span></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">License</div>
<div class="datagrid-content">AGPLv3</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Repository</div>
<div class="datagrid-content"><a href="https://git.techniverse.net/scriptos/keywarden" target="_blank">git.techniverse.net/scriptos/keywarden</a></div>
</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
+100
View File
@@ -0,0 +1,100 @@
{{define "content"}}
<div class="row row-deck row-cards">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-users"></i> User Management</h3>
<div class="card-actions">
<a href="/users/add" class="btn btn-primary">
<i class="ti ti-plus"></i> Add User
</a>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>MFA</th>
<th>Last Login</th>
<th>Created</th>
<th class="w-1">Actions</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
<td>
<i class="ti ti-user"></i> {{.Username}}
</td>
<td class="text-secondary">{{.Email}}</td>
<td>
{{if eq .Role "owner"}}
<span class="badge bg-purple-lt">Owner</span>
{{else if eq .Role "admin"}}
<span class="badge bg-red-lt">Admin</span>
{{else}}
<span class="badge bg-blue-lt">User</span>
{{end}}
</td>
<td>
{{if .LockedUntil}}
<span class="badge bg-danger-lt"><i class="ti ti-lock"></i> Locked</span>
{{else if .MustChangePassword}}
<span class="badge bg-warning-lt"><i class="ti ti-alert-triangle"></i> Password Change</span>
{{else}}
<span class="badge bg-success-lt"><i class="ti ti-check"></i> Active</span>
{{end}}
</td>
<td>
{{if .MFAEnabled}}
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> Enabled</span>
{{else}}
<span class="badge bg-secondary-lt"><i class="ti ti-shield-off"></i> Disabled</span>
{{end}}
</td>
<td class="text-secondary">
{{if .LastLoginAt}}
{{.LastLoginAt.Format "2006-01-02 15:04"}}
{{else}}
<span class="text-muted">Never</span>
{{end}}
</td>
<td class="text-secondary">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>
<div class="btn-list flex-nowrap">
{{if .LockedUntil}}
<form method="POST" action="/users/{{.ID}}/unlock" class="d-inline" title="Unlock Account">
<button type="submit" class="btn btn-sm btn-icon btn-outline-warning">
<i class="ti ti-lock-open"></i>
</button>
</form>
{{end}}
<a href="/users/{{.ID}}/edit" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
<i class="ti ti-edit"></i>
</a>
<form method="POST" action="/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this user?')">
<button type="submit" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
<i class="ti ti-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{{else}}
<tr>
<td colspan="9" class="text-center text-secondary">No users found.</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
+105
View File
@@ -0,0 +1,105 @@
{{define "content"}}
<div class="row row-deck row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-user-plus"></i> Add New User</h3>
</div>
<div class="card-body">
<form action="/users/add" method="post" autocomplete="off" id="addUserForm">
<div class="mb-3">
<label class="form-label required">Username</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
<input type="text" name="username" class="form-control" placeholder="Username" required>
</div>
</div>
<div class="mb-3">
<label class="form-label required">Email</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-mail"></i></span>
<input type="email" name="email" class="form-control" placeholder="user@example.com" required>
</div>
</div>
{{if .EmailEnabled}}
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="send_invitation" value="1" id="sendInvitation">
<span class="form-check-label">Send invitation email</span>
<span class="form-check-description">The user will receive an email with a link to set their password and complete registration. No manual password required.</span>
</label>
</div>
{{end}}
<div class="mb-3" id="passwordField">
<label class="form-label required">Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="password" class="form-control" placeholder="Password" id="passwordInput" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
{{if .PasswordPolicy}}
<small class="form-hint">
Min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}.
</small>
{{else}}
<small class="form-hint">Minimum 8 characters.</small>
{{end}}
</div>
<div class="mb-3">
<label class="form-label required">Role</label>
<select name="role" class="form-select">
<option value="user" selected>User</option>
{{with $.User}}
{{if eq .Role "owner"}}
<option value="admin">Admin</option>
<option value="owner">Owner</option>
{{end}}
{{end}}
</select>
</div>
<div class="mb-3" id="mustChangeField">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="must_change_password" value="1" checked>
<span class="form-check-label">Initial password</span>
<span class="form-check-description">User must change their password on next login.</span>
</label>
</div>
<div class="form-footer">
<a href="/users" class="btn btn-outline-secondary me-2">
<i class="ti ti-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="ti ti-user-plus"></i> Create User
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
(function() {
var cb = document.getElementById('sendInvitation');
if (!cb) return;
var pwField = document.getElementById('passwordField');
var pwInput = document.getElementById('passwordInput');
var mustChangeField = document.getElementById('mustChangeField');
function toggle() {
if (cb.checked) {
pwField.style.display = 'none';
pwInput.removeAttribute('required');
pwInput.value = '';
mustChangeField.style.display = 'none';
} else {
pwField.style.display = '';
pwInput.setAttribute('required', 'required');
mustChangeField.style.display = '';
}
}
cb.addEventListener('change', toggle);
toggle();
})();
</script>
{{end}}
+104
View File
@@ -0,0 +1,104 @@
{{define "content"}}
<div class="row row-deck row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-user-edit"></i> Edit User</h3>
</div>
<div class="card-body">
{{if .EditUser}}
<form action="/users/{{.EditUser.ID}}/edit" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label required">Username</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-user"></i></span>
<input type="text" name="username" class="form-control" value="{{.EditUser.Username}}" required>
</div>
</div>
<div class="mb-3">
<label class="form-label required">Email</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-mail"></i></span>
<input type="email" name="email" class="form-control" value="{{.EditUser.Email}}" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="password" class="form-control" placeholder="Leave empty to keep current" minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
{{if .PasswordPolicy}}
<small class="form-hint">
Min. {{.PasswordPolicy.MinLength}} characters{{if .PasswordPolicy.RequireUpper}}, uppercase{{end}}{{if .PasswordPolicy.RequireLower}}, lowercase{{end}}{{if .PasswordPolicy.RequireDigit}}, digit{{end}}{{if .PasswordPolicy.RequireSpecial}}, special char{{end}}. Leave empty to keep current.
</small>
{{else}}
<small class="form-hint">Leave empty to keep the current password.</small>
{{end}}
</div>
<div class="mb-3">
<label class="form-label required">Role</label>
<select name="role" class="form-select">
<option value="user" {{if eq .EditUser.Role "user"}}selected{{end}}>User</option>
{{with $.User}}
{{if eq .Role "owner"}}
<option value="admin" {{if eq $.EditUser.Role "admin"}}selected{{end}}>Admin</option>
<option value="owner" {{if eq $.EditUser.Role "owner"}}selected{{end}}>Owner</option>
{{end}}
{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-label">MFA Status</label>
<div>
{{if .EditUser.MFAEnabled}}
<span class="badge bg-green-lt"><i class="ti ti-shield-check"></i> MFA Enabled</span>
{{else}}
<span class="badge bg-secondary-lt"><i class="ti ti-shield-off"></i> MFA Disabled</span>
{{end}}
</div>
</div>
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="must_change_password" value="1" {{if .EditUser.MustChangePassword}}checked{{end}}>
<span class="form-check-label">Force password change</span>
<span class="form-check-description">User must change their password on next login.</span>
</label>
</div>
{{if .EditUser.LockedUntil}}
<div class="mb-3">
<div class="alert alert-danger mb-0">
<div class="d-flex align-items-center">
<div class="me-3"><i class="ti ti-lock icon alert-icon"></i></div>
<div class="flex-fill">
<strong>Account locked</strong> until {{.EditUser.LockedUntil.Format "2006-01-02 15:04"}} ({{.EditUser.FailedLoginAttempts}} failed attempts)
</div>
</div>
</div>
</div>
{{end}}
<div class="form-footer">
<a href="/users" class="btn btn-outline-secondary me-2">
<i class="ti ti-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="ti ti-device-floppy"></i> Save Changes
</button>
</div>
</form>
{{if .EditUser.LockedUntil}}
<hr>
<form method="POST" action="/users/{{.EditUser.ID}}/unlock" onsubmit="return confirm('Unlock this account?')">
<button type="submit" class="btn btn-warning">
<i class="ti ti-lock-open"></i> Unlock Account
</button>
</form>
{{end}}
{{else}}
<p class="text-secondary">User not found.</p>
{{end}}
</div>
</div>
</div>
</div>
{{end}}