feat: add automatic update checker with version injection
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Successful in 5m45s
Security Scan / Go Vulnerability Check (pull_request) Failing after 5m42s

- Add internal/updater package (queries Gitea releases API every 6h)

- Inject version at build time via -ldflags (-X main.Version)

- Show update badge in header for admin/owner users

- Show version on system info page

- Add VERSION build arg to Dockerfile

- Update docs (deployment, architecture, admin-guide, contributing, README)
This commit is contained in:
2026-04-07 23:13:26 +02:00
parent 465a44fae9
commit 653592e68f
11 changed files with 269 additions and 6 deletions

View File

@@ -11,7 +11,9 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/
ARG VERSION=dev
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X main.Version=${VERSION}" ./cmd/keywarden/
# Stage 2: Runtime # Stage 2: Runtime
FROM alpine:3.21 FROM alpine:3.21

View File

@@ -29,6 +29,7 @@
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users - **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection - **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
- **Audit Log** — Every action tracked with user, IP, timestamp, and details - **Audit Log** — Every action tracked with user, IP, timestamp, and details
- **Update Notifications** — Automatic update check with version badge in the header for admins
- **Encrypted Backup/Restore** — Full database export with password-based encryption - **Encrypted Backup/Restore** — Full database export with password-based encryption
- **Docker-Native** — Single container with embedded SQLite, no external database required - **Docker-Native** — Single container with embedded SQLite, no external database required

View File

@@ -24,10 +24,18 @@ import (
"git.techniverse.net/scriptos/keywarden/internal/mail" "git.techniverse.net/scriptos/keywarden/internal/mail"
"git.techniverse.net/scriptos/keywarden/internal/security" "git.techniverse.net/scriptos/keywarden/internal/security"
"git.techniverse.net/scriptos/keywarden/internal/servers" "git.techniverse.net/scriptos/keywarden/internal/servers"
"git.techniverse.net/scriptos/keywarden/internal/updater"
"git.techniverse.net/scriptos/keywarden/internal/worker" "git.techniverse.net/scriptos/keywarden/internal/worker"
"git.techniverse.net/scriptos/keywarden/web" "git.techniverse.net/scriptos/keywarden/web"
) )
// Version is set at build time via -ldflags:
//
// go build -ldflags "-X main.Version=v1.0.0" ./cmd/keywarden/
//
// When building with Docker, pass --build-arg VERSION=v1.0.0
var Version = "dev"
func main() { func main() {
// Handle CLI subcommands before starting the server // Handle CLI subcommands before starting the server
if len(os.Args) > 1 { if len(os.Args) > 1 {
@@ -47,7 +55,7 @@ func main() {
// Initialize structured logging // Initialize structured logging
logging.Init(cfg.LogLevel) logging.Init(cfg.LogLevel)
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment") logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", Version)
logging.Info(" https://git.techniverse.net/scriptos/keywarden") logging.Info(" https://git.techniverse.net/scriptos/keywarden")
// Validate data paths relative paths inside a container bypass the // Validate data paths relative paths inside a container bypass the
@@ -117,8 +125,11 @@ func main() {
logging.Info("Base URL: %s", cfg.BaseURL) logging.Info("Base URL: %s", cfg.BaseURL)
} }
// Initialize update checker
updaterSvc := updater.NewService(Version)
// Setup HTTP handlers // Setup HTTP handlers
handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL) handler := handlers.New(authSvc, keysSvc, serversSvc, deploySvc, auditSvc, cronSvc, workerSvc, mailSvc, db, web.TemplateFS, web.StaticFS, cfg.DataDir, cfg.SecureCookies, cfg.BaseURL, updaterSvc)
mux := http.NewServeMux() mux := http.NewServeMux()
handler.RegisterRoutes(mux) handler.RegisterRoutes(mux)
@@ -148,6 +159,10 @@ func main() {
workerSvc.Start() workerSvc.Start()
defer workerSvc.Stop() defer workerSvc.Stop()
// Start update checker
updaterSvc.Start()
defer updaterSvc.Stop()
// Start server // Start server
addr := ":" + cfg.Port addr := ":" + cfg.Port
logging.Info("Server starting on http://0.0.0.0%s", addr) logging.Info("Server starting on http://0.0.0.0%s", addr)
@@ -278,7 +293,7 @@ func handleResetPassword(args []string) {
// printUsage displays available CLI subcommands // printUsage displays available CLI subcommands
func printUsage() { func printUsage() {
fmt.Println("Keywarden - Centralized SSH Key Management and Deployment") fmt.Printf("Keywarden %s - Centralized SSH Key Management and Deployment\n", Version)
fmt.Println() fmt.Println()
fmt.Println("Usage:") fmt.Println("Usage:")
fmt.Println(" keywarden Start the server") fmt.Println(" keywarden Start the server")

View File

@@ -209,12 +209,19 @@ Deleting a user removes their SSH keys, server records, and all related data (CA
Navigate to **System** to view runtime information: Navigate to **System** to view runtime information:
- Application version (with update badge if a newer release is available)
- Go version, OS, architecture - Go version, OS, architecture
- CPU count, goroutine count - CPU count, goroutine count
- Memory allocation - Memory allocation
- Runtime environment (Docker or native) - Runtime environment (Docker or native)
- Hostname and uptime - Hostname and uptime
## Update Notifications
Keywarden automatically checks for new releases in the background by querying the Gitea releases API. If a newer version is available, a yellow update badge is displayed in the top header for **Admin** and **Owner** users. The badge links directly to the release page on Gitea.
The update checker is only active when the application was built with a version tag (via `--build-arg VERSION=...`). Development builds (`dev`) skip the check entirely.
## Admin Settings (Owner Only) ## Admin Settings (Owner Only)
See [Roles & Permissions](roles.md) for details on which settings are owner-only. See [Roles & Permissions](roles.md) for details on which settings are owner-only.

View File

@@ -39,6 +39,7 @@ internal/
security/ ← CSRF, security headers, rate limiting, proxy detection security/ ← CSRF, security headers, rate limiting, proxy detection
servers/ ← Server and server group management, access assignments servers/ ← Server and server group management, access assignments
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448) sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
updater/ ← Background update checker (Gitea releases API)
worker/ ← Background key enforcement worker (Bastillion-style) worker/ ← Background key enforcement worker (Bastillion-style)
web/ web/
embed.go ← Go embed directives for templates and static files embed.go ← Go embed directives for templates and static files

View File

@@ -42,6 +42,9 @@ go mod download
# Build # Build
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/ CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
# Build with version (optional, enables update checker)
CGO_ENABLED=1 go build -ldflags="-X 'main.Version=v1.0.0'" -o keywarden ./cmd/keywarden/
# Run # Run
./keywarden ./keywarden
``` ```
@@ -82,7 +85,8 @@ keywarden/
│ │ ├── ratelimit.go # IP-based rate limiting middleware │ │ ├── ratelimit.go # IP-based rate limiting middleware
│ │ └── sizelimit.go # Request body size limit middleware │ │ └── sizelimit.go # Request body size limit middleware
│ ├── servers/servers.go # Server and group management, access assignments │ ├── servers/servers.go # Server and group management, access assignments
── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448) ── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
│ └── updater/updater.go # Background update checker (Gitea releases API)
├── web/ ├── web/
│ ├── embed.go # Go embed directives │ ├── embed.go # Go embed directives
│ ├── static/ # CSS, JS, fonts (Tabler UI) │ ├── static/ # CSS, JS, fonts (Tabler UI)

View File

@@ -32,6 +32,9 @@ docker compose build
# Or build manually # Or build manually
docker build -t keywarden . docker build -t keywarden .
# Build with a specific version tag (recommended for releases)
docker build --build-arg VERSION=v1.0.0 -t keywarden:v1.0.0 .
``` ```
### Multi-Stage Build ### Multi-Stage Build
@@ -43,6 +46,8 @@ The Dockerfile uses a two-stage build:
The runtime container runs as a non-root user (`keywarden`). The runtime container runs as a non-root user (`keywarden`).
The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. This enables the built-in update checker to compare the running version against the latest Gitea release. If omitted, the version defaults to `dev` and the update checker is disabled.
### Docker Compose ### Docker Compose
A complete `docker-compose.yml`: A complete `docker-compose.yml`:

View File

@@ -39,6 +39,7 @@ import (
"git.techniverse.net/scriptos/keywarden/internal/models" "git.techniverse.net/scriptos/keywarden/internal/models"
"git.techniverse.net/scriptos/keywarden/internal/security" "git.techniverse.net/scriptos/keywarden/internal/security"
"git.techniverse.net/scriptos/keywarden/internal/servers" "git.techniverse.net/scriptos/keywarden/internal/servers"
"git.techniverse.net/scriptos/keywarden/internal/updater"
"git.techniverse.net/scriptos/keywarden/internal/worker" "git.techniverse.net/scriptos/keywarden/internal/worker"
) )
@@ -59,6 +60,7 @@ type Handler struct {
cron *cron.Service cron *cron.Service
worker *worker.Service worker *worker.Service
mail *mail.Service mail *mail.Service
updater *updater.Service
db *database.DB // direct database access for backup/restore db *database.DB // direct database access for backup/restore
templates map[string]*template.Template templates map[string]*template.Template
sessions map[string]*sessionData // cookie -> session data with timeout tracking sessions map[string]*sessionData // cookie -> session data with timeout tracking
@@ -251,7 +253,7 @@ func formatUptime(start time.Time) string {
} }
// New creates a new Handler // New creates a new Handler
func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string) *Handler { func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, workerSvc *worker.Service, mailSvc *mail.Service, db *database.DB, templateFS embed.FS, staticFS embed.FS, dataDir string, secureCookies bool, baseURL string, updaterSvc *updater.Service) *Handler {
// Create sub-FS so /static/css/... maps to static/css/... in embed // Create sub-FS so /static/css/... maps to static/css/... in embed
staticSub, err := fs.Sub(staticFS, "static") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
@@ -273,6 +275,7 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
cron: cronSvc, cron: cronSvc,
worker: workerSvc, worker: workerSvc,
mail: mailSvc, mail: mailSvc,
updater: updaterSvc,
db: db, db: db,
sessions: make(map[string]*sessionData), sessions: make(map[string]*sessionData),
pending: make(map[string]int64), pending: make(map[string]int64),
@@ -302,6 +305,18 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
} }
return name return name
}, },
"appVersion": func() string {
return h.updater.CurrentVersion()
},
"updateAvailable": func() bool {
return h.updater.HasUpdate()
},
"latestVersion": func() string {
return h.updater.LatestVersion()
},
"releaseURL": func() string {
return h.updater.ReleaseURL()
},
} }
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html") baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")

193
internal/updater/updater.go Normal file
View File

@@ -0,0 +1,193 @@
// Keywarden - Centralized SSH Key Management and Deployment
// Copyright (C) 2026 Patrick Asmus (scriptos)
// SPDX-License-Identifier: AGPL-3.0-or-later
package updater
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.techniverse.net/scriptos/keywarden/internal/logging"
)
const (
// Gitea API endpoint for releases
releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5"
// How often to check for updates
checkInterval = 6 * time.Hour
// HTTP timeout for API requests
httpTimeout = 15 * time.Second
)
// giteaRelease represents the relevant fields from the Gitea releases API
type giteaRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
// Service checks for new releases in the background
type Service struct {
currentVersion string
mu sync.RWMutex
latestVersion string
releaseURL string
hasUpdate bool
stopCh chan struct{}
}
// NewService creates an update checker. Pass the current application version
// (e.g. "v1.0.0" or "dev"). The checker runs in the background and queries
// the Gitea releases API periodically.
func NewService(currentVersion string) *Service {
return &Service{
currentVersion: currentVersion,
stopCh: make(chan struct{}),
}
}
// Start begins periodic update checks in the background.
func (s *Service) Start() {
// Don't check if running a dev build
if s.currentVersion == "" || s.currentVersion == "dev" {
logging.Info("Update checker disabled (development build)")
return
}
logging.Info("Update checker started (current version: %s, checking every %s)", s.currentVersion, checkInterval)
go s.run()
}
// Stop signals the background goroutine to exit.
func (s *Service) Stop() {
close(s.stopCh)
}
// HasUpdate returns true if a newer version is available.
func (s *Service) HasUpdate() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.hasUpdate
}
// LatestVersion returns the tag name of the latest release (e.g. "v1.2.0").
func (s *Service) LatestVersion() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.latestVersion
}
// ReleaseURL returns the HTML link to the latest release page on Gitea.
func (s *Service) ReleaseURL() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.releaseURL
}
// CurrentVersion returns the running application version.
func (s *Service) CurrentVersion() string {
return s.currentVersion
}
func (s *Service) run() {
// Initial check shortly after startup
timer := time.NewTimer(30 * time.Second)
defer timer.Stop()
for {
select {
case <-s.stopCh:
return
case <-timer.C:
s.check()
timer.Reset(checkInterval)
}
}
}
func (s *Service) check() {
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Get(releasesAPI)
if err != nil {
logging.Warn("Update check failed: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logging.Warn("Update check: Gitea API returned status %d", resp.StatusCode)
return
}
var releases []giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
logging.Warn("Update check: failed to parse response: %v", err)
return
}
// Find the latest stable release (not draft, not prerelease)
for _, rel := range releases {
if rel.Draft || rel.Prerelease || rel.TagName == "" {
continue
}
s.mu.Lock()
s.latestVersion = rel.TagName
s.releaseURL = rel.HTMLURL
s.hasUpdate = isNewer(rel.TagName, s.currentVersion)
s.mu.Unlock()
if s.HasUpdate() {
logging.Info("New version available: %s (current: %s)", rel.TagName, s.currentVersion)
}
return
}
}
// isNewer returns true if latest is a higher version than current.
// Both may optionally have a "v" prefix (e.g. "v1.2.3").
func isNewer(latest, current string) bool {
latestParts := parseVersion(latest)
currentParts := parseVersion(current)
for i := 0; i < len(latestParts) || i < len(currentParts); i++ {
l, c := 0, 0
if i < len(latestParts) {
l = latestParts[i]
}
if i < len(currentParts) {
c = currentParts[i]
}
if l > c {
return true
}
if l < c {
return false
}
}
return false
}
// parseVersion strips the "v" prefix and splits "1.2.3" into [1, 2, 3].
func parseVersion(v string) []int {
v = strings.TrimPrefix(v, "v")
parts := strings.Split(v, ".")
nums := make([]int, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
break
}
nums = append(nums, n)
}
return nums
}

View File

@@ -582,6 +582,15 @@
</div> </div>
<!-- Spacer --> <!-- Spacer -->
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
<!-- Update Available Badge (Admin/Owner only) -->
{{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}}
<div class="nav-item d-none d-md-flex me-2">
<a href="{{releaseURL}}" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Update verfügbar: {{latestVersion}} (aktuell: {{appVersion}})">
<i class="ti ti-download" style="color: #fbbf24;"></i>
<span style="color: #fbbf24;">Update {{latestVersion}}</span>
</a>
</div>
{{end}}{{end}}
<!-- Repository Link --> <!-- Repository Link -->
<div class="nav-item d-none d-md-flex me-2"> <div class="nav-item d-none d-md-flex me-2">
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea"> <a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">

View File

@@ -9,6 +9,17 @@
<div class="card-body"> <div class="card-body">
{{with .SystemInfo}} {{with .SystemInfo}}
<div class="datagrid"> <div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Keywarden Version</div>
<div class="datagrid-content">
<span class="badge bg-blue-lt">{{appVersion}}</span>
{{if updateAvailable}}
<a href="{{releaseURL}}" target="_blank" rel="noopener noreferrer" class="badge bg-yellow-lt ms-1" title="Update verfügbar">
<i class="ti ti-download"></i> {{latestVersion}} verfügbar
</a>
{{end}}
</div>
</div>
<div class="datagrid-item"> <div class="datagrid-item">
<div class="datagrid-title">Runtime Environment</div> <div class="datagrid-title">Runtime Environment</div>
<div class="datagrid-content"> <div class="datagrid-content">