Merge pull request 'v0.2.0-alpha' (#2) from v0.2.0-alpha into master
Release Docker Image / Build & Push Docker Image (release) Successful in 5m33s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-05 17:56:47 +00:00
11 changed files with 263 additions and 72 deletions
+9 -4
View File
@@ -20,10 +20,15 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
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
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
# The Dockerfile already sets correct defaults (/data/...). Only override
# if you know what you are doing. Do NOT use relative paths (./data/...)
# they resolve to /app/data/ inside the container and bypass the
# persistent volume mount at /data, causing DATA LOSS on restart.
# 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.
+5 -5
View File
@@ -16,17 +16,17 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywar
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl su-exec
RUN addgroup -S keywarden && adduser -S keywarden -G keywarden
WORKDIR /app
COPY --from=builder /build/keywarden .
COPY entrypoint.sh .
RUN mkdir -p /data/keys /data/master /data/avatars && \
chown -R keywarden:keywarden /data /app
USER keywarden
chown -R keywarden:keywarden /data /app && \
chmod +x /app/entrypoint.sh
ENV KEYWARDEN_PORT=8080
ENV KEYWARDEN_DB_PATH=/data/keywarden.db
@@ -42,4 +42,4 @@ 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"]
ENTRYPOINT ["/app/entrypoint.sh"]
+1
View File
@@ -138,6 +138,7 @@ Join the **Keywarden Matrix chat** to discuss the project, ask questions, or sha
|---|---|
| **Primary (Gitea)** | [git.techniverse.net/scriptos/keywarden](https://git.techniverse.net/scriptos/keywarden) |
| **Mirror (GitHub)** | [github.com/pscriptos/keywarden](https://github.com/pscriptos/keywarden) |
| **Container Registry** | [git.techniverse.net/scriptos/-/packages/container/keywarden](https://git.techniverse.net/scriptos/-/packages/container/keywarden) |
The **primary repository** is hosted on Gitea. The GitHub repository is a read-only mirror.
+36
View File
@@ -7,6 +7,8 @@ package main
import (
"net/http"
"os"
"path/filepath"
"strings"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
@@ -34,6 +36,10 @@ func main() {
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
// Validate data paths relative paths inside a container bypass the
// persistent volume mount and lead to silent data loss on restart.
validateDataPaths(cfg)
// Ensure data directories exist
for _, dir := range []string{cfg.DataDir, cfg.KeysDir, cfg.MasterDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
@@ -152,3 +158,33 @@ func getEnvWithLegacy(primary, legacy, fallback string) string {
}
return fallback
}
// validateDataPaths checks for a common misconfiguration: relative paths
// (e.g. ./data/...) that resolve to the container's working directory instead
// of the persistent volume mount. This would cause silent data loss on every
// container restart.
func validateDataPaths(cfg *config.Config) {
paths := map[string]string{
"KEYWARDEN_DB_PATH": cfg.DBPath,
"KEYWARDEN_DATA_DIR": cfg.DataDir,
"KEYWARDEN_KEYS_DIR": cfg.KeysDir,
"KEYWARDEN_MASTER_DIR": cfg.MasterDir,
}
for envVar, p := range paths {
if p == "" {
continue
}
abs, err := filepath.Abs(p)
if err != nil {
continue
}
// Detect relative paths that resolve outside /data (the expected volume).
if !filepath.IsAbs(p) || (!strings.HasPrefix(abs, "/data") && !strings.HasPrefix(abs, `\data`)) {
// Only warn don't block startup for non-Docker environments.
if strings.HasPrefix(p, "./") || strings.HasPrefix(p, "../") || (!filepath.IsAbs(p) && p != "") {
logging.Warn("⚠ %s is a relative path (%s → %s). Inside a Docker container this may bypass the persistent volume and cause DATA LOSS on restart. Use an absolute path like /data/... instead.", envVar, p, abs)
}
}
}
}
+1
View File
@@ -1,6 +1,7 @@
services:
keywarden:
image: git.techniverse.net/scriptos/keywarden:latest
build: .
container_name: keywarden
restart: unless-stopped
ports:
+17 -1
View File
@@ -8,7 +8,23 @@ Keywarden is designed as a single-container application with an embedded SQLite
### Docker Image
Build from source or use the pre-built image:
Keywarden provides pre-built Docker images via the container registry:
**Container Registry:** [git.techniverse.net/scriptos/-/packages/container/keywarden](https://git.techniverse.net/scriptos/-/packages/container/keywarden)
Pull the latest image:
```bash
docker pull git.techniverse.net/scriptos/keywarden:latest
```
Or pull a specific version:
```bash
docker pull git.techniverse.net/scriptos/keywarden:v0.1.1-alpha
```
Alternatively, build from source:
```bash
# Build from source
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Keywarden Docker Entrypoint
# Ensures data directories exist with correct ownership before
# dropping privileges to the keywarden user.
set -e
# Create data directories (bind-mount from host may be owned by root)
mkdir -p /data/keys /data/master /data/avatars
# Fix ownership so the unprivileged keywarden user can write
chown -R keywarden:keywarden /data
# Drop privileges and exec the application
exec su-exec keywarden ./keywarden "$@"
+30
View File
@@ -156,12 +156,23 @@ func (s *Service) HasUsers() (bool, error) {
// EnsureAdmin creates a default owner user if no users exist.
// It auto-generates a secure password and returns (created, generatedPassword, error).
// A persistent flag ("initial_setup_complete") is stored in the settings table
// so that an admin account is never re-created after the initial setup, even
// if the users table is unexpectedly empty (e.g. due to a misconfigured volume).
func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
// Defence-in-depth: if the initial setup was already completed once,
// never auto-create another admin even when the users table is empty.
if s.isInitialSetupComplete() {
return false, "", nil
}
hasUsers, err := s.HasUsers()
if err != nil {
return false, "", err
}
if hasUsers {
// Users exist but no flag yet (upgrade path) set the flag now.
s.markInitialSetupComplete()
return false, "", nil
}
@@ -183,9 +194,28 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
if err != nil {
return false, "", err
}
// Mark initial setup as complete so the password is never regenerated.
s.markInitialSetupComplete()
return true, password, nil
}
// isInitialSetupComplete checks whether the initial admin setup has already
// been performed by looking for a flag in the settings table.
func (s *Service) isInitialSetupComplete() bool {
var val string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_setup_complete'`).Scan(&val)
return err == nil && val == "true"
}
// markInitialSetupComplete persists the initial-setup flag in the settings table.
func (s *Service) markInitialSetupComplete() {
s.db.Exec(
`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_setup_complete', 'true', CURRENT_TIMESTAMP)`,
)
}
// generateSecurePassword creates a cryptographically secure random password
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+20 -1
View File
@@ -236,7 +236,7 @@ func TestEnsureAdmin(t *testing.T) {
t.Fatalf("Expected owner role, got %q", user.Role)
}
// Second call should be no-op
// Second call should be no-op (users exist)
created2, _, err := svc.EnsureAdmin("admin2", "admin2@test.com")
if err != nil {
t.Fatalf("Second EnsureAdmin should not fail: %v", err)
@@ -250,6 +250,25 @@ func TestEnsureAdmin(t *testing.T) {
if err != ErrInvalidCredentials {
t.Fatalf("admin2 should not have been created")
}
// initial_setup_complete flag should be set
if !svc.isInitialSetupComplete() {
t.Fatal("Expected initial_setup_complete flag to be set after EnsureAdmin")
}
// Even if all users are deleted, EnsureAdmin must NOT create a new admin
// because the initial_setup_complete flag is set (defence-in-depth).
_, err = db.Exec(`DELETE FROM users`)
if err != nil {
t.Fatalf("Failed to delete all users: %v", err)
}
created3, _, err := svc.EnsureAdmin("admin3", "admin3@test.com")
if err != nil {
t.Fatalf("Third EnsureAdmin should not fail: %v", err)
}
if created3 {
t.Fatal("EnsureAdmin must not create a user when initial_setup_complete flag is set")
}
}
func TestGetAllUsers(t *testing.T) {
+15 -5
View File
@@ -306,7 +306,6 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
"admin_settings", "system_info",
"cron", "cron_add", "cron_edit",
"assignments", "assignments_add", "assignments_edit",
"force_password_change",
}
for _, page := range pages {
pageContent, err := fs.ReadFile(templateFS, "templates/"+page+".html")
@@ -335,6 +334,17 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
}
h.templates["login"] = loginTmpl
// Force password change page has its own layout (standalone, no sidebar)
fpcContent, err := fs.ReadFile(templateFS, "templates/force_password_change.html")
if err != nil {
logging.Fatal("Failed to read force_password_change template: %v", err)
}
fpcTmpl, err := template.New("force_password_change").Funcs(funcMap).Parse(string(fpcContent))
if err != nil {
logging.Fatal("Failed to parse force_password_change: %v", err)
}
h.templates["force_password_change"] = fpcTmpl
// MFA required page has its own layout (standalone, no sidebar)
mfaReqContent, err := fs.ReadFile(templateFS, "templates/mfa_required.html")
if err != nil {
@@ -2225,7 +2235,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
User: user,
PasswordPolicy: &policy,
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2239,7 +2249,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: "Passwords do not match."},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2250,7 +2260,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: err.Error()},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2261,7 +2271,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: "Failed to change password: " + err.Error()},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
+114 -56
View File
@@ -1,65 +1,123 @@
{{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>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Password Change 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-lock-exclamation"></i> Password Change Required
</h2>
{{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 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>
</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}}">
{{if .Flash}}
<div class="alert alert-{{.Flash.Type}}">
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
</div>
{{end}}
{{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 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}}
<!-- CSRF Protection -->
<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>