feat: add automatic update checker with version injection
- 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:
@@ -11,7 +11,9 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywarden/
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w -X main.Version=${VERSION}" ./cmd/keywarden/
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:3.21
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- **Two-Factor Authentication** — TOTP-based MFA, optionally enforced for all users
|
||||
- **Password Policies & Account Lockout** — Configurable complexity rules and brute-force protection
|
||||
- **Audit Log** — Every action tracked with user, IP, timestamp, and details
|
||||
- **Update Notifications** — Automatic update check with version badge in the header for admins
|
||||
- **Encrypted Backup/Restore** — Full database export with password-based encryption
|
||||
- **Docker-Native** — Single container with embedded SQLite, no external database required
|
||||
|
||||
|
||||
@@ -24,10 +24,18 @@ import (
|
||||
"git.techniverse.net/scriptos/keywarden/internal/mail"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||
"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() {
|
||||
// Handle CLI subcommands before starting the server
|
||||
if len(os.Args) > 1 {
|
||||
@@ -47,7 +55,7 @@ func main() {
|
||||
// Initialize structured logging
|
||||
logging.Init(cfg.LogLevel)
|
||||
|
||||
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
|
||||
logging.Info("🔑 Keywarden %s - Centralized SSH Key Management and Deployment", Version)
|
||||
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
|
||||
|
||||
// Validate data paths – relative paths inside a container bypass the
|
||||
@@ -117,8 +125,11 @@ func main() {
|
||||
logging.Info("Base URL: %s", cfg.BaseURL)
|
||||
}
|
||||
|
||||
// Initialize update checker
|
||||
updaterSvc := updater.NewService(Version)
|
||||
|
||||
// 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()
|
||||
handler.RegisterRoutes(mux)
|
||||
@@ -148,6 +159,10 @@ func main() {
|
||||
workerSvc.Start()
|
||||
defer workerSvc.Stop()
|
||||
|
||||
// Start update checker
|
||||
updaterSvc.Start()
|
||||
defer updaterSvc.Stop()
|
||||
|
||||
// Start server
|
||||
addr := ":" + cfg.Port
|
||||
logging.Info("Server starting on http://0.0.0.0%s", addr)
|
||||
@@ -278,7 +293,7 @@ func handleResetPassword(args []string) {
|
||||
|
||||
// printUsage displays available CLI subcommands
|
||||
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("Usage:")
|
||||
fmt.Println(" keywarden Start the server")
|
||||
|
||||
@@ -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:
|
||||
|
||||
- Application version (with update badge if a newer release is available)
|
||||
- Go version, OS, architecture
|
||||
- CPU count, goroutine count
|
||||
- Memory allocation
|
||||
- Runtime environment (Docker or native)
|
||||
- Hostname and uptime
|
||||
|
||||
## Update Notifications
|
||||
|
||||
Keywarden automatically checks for new releases in the background by querying the Gitea releases API. If a newer version is available, a yellow update badge is displayed in the top header for **Admin** and **Owner** users. The badge links directly to the release page on Gitea.
|
||||
|
||||
The update checker is only active when the application was built with a version tag (via `--build-arg VERSION=...`). Development builds (`dev`) skip the check entirely.
|
||||
|
||||
## Admin Settings (Owner Only)
|
||||
|
||||
See [Roles & Permissions](roles.md) for details on which settings are owner-only.
|
||||
|
||||
@@ -39,6 +39,7 @@ internal/
|
||||
security/ ← CSRF, security headers, rate limiting, proxy detection
|
||||
servers/ ← Server and server group management, access assignments
|
||||
sshutil/ ← SSH key generation (RSA, Ed25519, Ed448)
|
||||
updater/ ← Background update checker (Gitea releases API)
|
||||
worker/ ← Background key enforcement worker (Bastillion-style)
|
||||
web/
|
||||
embed.go ← Go embed directives for templates and static files
|
||||
|
||||
@@ -42,6 +42,9 @@ go mod download
|
||||
# Build
|
||||
CGO_ENABLED=1 go build -o keywarden ./cmd/keywarden/
|
||||
|
||||
# Build with version (optional, enables update checker)
|
||||
CGO_ENABLED=1 go build -ldflags="-X 'main.Version=v1.0.0'" -o keywarden ./cmd/keywarden/
|
||||
|
||||
# Run
|
||||
./keywarden
|
||||
```
|
||||
@@ -82,7 +85,8 @@ keywarden/
|
||||
│ │ ├── ratelimit.go # IP-based rate limiting middleware
|
||||
│ │ └── sizelimit.go # Request body size limit middleware
|
||||
│ ├── servers/servers.go # Server and group management, access assignments
|
||||
│ └── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
|
||||
│ ├── sshutil/keygen.go # SSH key generation (RSA, Ed25519, Ed448)
|
||||
│ └── updater/updater.go # Background update checker (Gitea releases API)
|
||||
├── web/
|
||||
│ ├── embed.go # Go embed directives
|
||||
│ ├── static/ # CSS, JS, fonts (Tabler UI)
|
||||
|
||||
@@ -32,6 +32,9 @@ docker compose build
|
||||
|
||||
# Or build manually
|
||||
docker build -t keywarden .
|
||||
|
||||
# Build with a specific version tag (recommended for releases)
|
||||
docker build --build-arg VERSION=v1.0.0 -t keywarden:v1.0.0 .
|
||||
```
|
||||
|
||||
### Multi-Stage Build
|
||||
@@ -43,6 +46,8 @@ The Dockerfile uses a two-stage build:
|
||||
|
||||
The runtime container runs as a non-root user (`keywarden`).
|
||||
|
||||
The build accepts an optional `VERSION` build arg (e.g. `--build-arg VERSION=v1.0.0`) which is injected into the binary via `-ldflags`. 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
|
||||
|
||||
A complete `docker-compose.yml`:
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/security"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/updater"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/worker"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,7 @@ type Handler struct {
|
||||
cron *cron.Service
|
||||
worker *worker.Service
|
||||
mail *mail.Service
|
||||
updater *updater.Service
|
||||
db *database.DB // direct database access for backup/restore
|
||||
templates map[string]*template.Template
|
||||
sessions map[string]*sessionData // cookie -> session data with timeout tracking
|
||||
@@ -251,7 +253,7 @@ func formatUptime(start time.Time) string {
|
||||
}
|
||||
|
||||
// New creates a new Handler
|
||||
func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Service, deploySvc *deploy.Service, auditSvc *audit.Service, cronSvc *cron.Service, 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
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
@@ -273,6 +275,7 @@ func New(authSvc *auth.Service, keysSvc *keys.Service, serversSvc *servers.Servi
|
||||
cron: cronSvc,
|
||||
worker: workerSvc,
|
||||
mail: mailSvc,
|
||||
updater: updaterSvc,
|
||||
db: db,
|
||||
sessions: make(map[string]*sessionData),
|
||||
pending: make(map[string]int64),
|
||||
@@ -302,6 +305,18 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
|
||||
}
|
||||
return name
|
||||
},
|
||||
"appVersion": func() string {
|
||||
return h.updater.CurrentVersion()
|
||||
},
|
||||
"updateAvailable": func() bool {
|
||||
return h.updater.HasUpdate()
|
||||
},
|
||||
"latestVersion": func() string {
|
||||
return h.updater.LatestVersion()
|
||||
},
|
||||
"releaseURL": func() string {
|
||||
return h.updater.ReleaseURL()
|
||||
},
|
||||
}
|
||||
|
||||
baseLayout, err := fs.ReadFile(templateFS, "templates/layout/base.html")
|
||||
|
||||
193
internal/updater/updater.go
Normal file
193
internal/updater/updater.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
)
|
||||
|
||||
const (
|
||||
// Gitea API endpoint for releases
|
||||
releasesAPI = "https://git.techniverse.net/api/v1/repos/scriptos/keywarden/releases?limit=5"
|
||||
// How often to check for updates
|
||||
checkInterval = 6 * time.Hour
|
||||
// HTTP timeout for API requests
|
||||
httpTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// giteaRelease represents the relevant fields from the Gitea releases API
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// Service checks for new releases in the background
|
||||
type Service struct {
|
||||
currentVersion string
|
||||
|
||||
mu sync.RWMutex
|
||||
latestVersion string
|
||||
releaseURL string
|
||||
hasUpdate bool
|
||||
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewService creates an update checker. Pass the current application version
|
||||
// (e.g. "v1.0.0" or "dev"). The checker runs in the background and queries
|
||||
// the Gitea releases API periodically.
|
||||
func NewService(currentVersion string) *Service {
|
||||
return &Service{
|
||||
currentVersion: currentVersion,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic update checks in the background.
|
||||
func (s *Service) Start() {
|
||||
// Don't check if running a dev build
|
||||
if s.currentVersion == "" || s.currentVersion == "dev" {
|
||||
logging.Info("Update checker disabled (development build)")
|
||||
return
|
||||
}
|
||||
logging.Info("Update checker started (current version: %s, checking every %s)", s.currentVersion, checkInterval)
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop signals the background goroutine to exit.
|
||||
func (s *Service) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// HasUpdate returns true if a newer version is available.
|
||||
func (s *Service) HasUpdate() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.hasUpdate
|
||||
}
|
||||
|
||||
// LatestVersion returns the tag name of the latest release (e.g. "v1.2.0").
|
||||
func (s *Service) LatestVersion() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.latestVersion
|
||||
}
|
||||
|
||||
// ReleaseURL returns the HTML link to the latest release page on Gitea.
|
||||
func (s *Service) ReleaseURL() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.releaseURL
|
||||
}
|
||||
|
||||
// CurrentVersion returns the running application version.
|
||||
func (s *Service) CurrentVersion() string {
|
||||
return s.currentVersion
|
||||
}
|
||||
|
||||
func (s *Service) run() {
|
||||
// Initial check shortly after startup
|
||||
timer := time.NewTimer(30 * time.Second)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-timer.C:
|
||||
s.check()
|
||||
timer.Reset(checkInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) check() {
|
||||
client := &http.Client{Timeout: httpTimeout}
|
||||
|
||||
resp, err := client.Get(releasesAPI)
|
||||
if err != nil {
|
||||
logging.Warn("Update check failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logging.Warn("Update check: Gitea API returned status %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var releases []giteaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
logging.Warn("Update check: failed to parse response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the latest stable release (not draft, not prerelease)
|
||||
for _, rel := range releases {
|
||||
if rel.Draft || rel.Prerelease || rel.TagName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.latestVersion = rel.TagName
|
||||
s.releaseURL = rel.HTMLURL
|
||||
s.hasUpdate = isNewer(rel.TagName, s.currentVersion)
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.HasUpdate() {
|
||||
logging.Info("New version available: %s (current: %s)", rel.TagName, s.currentVersion)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// isNewer returns true if latest is a higher version than current.
|
||||
// Both may optionally have a "v" prefix (e.g. "v1.2.3").
|
||||
func isNewer(latest, current string) bool {
|
||||
latestParts := parseVersion(latest)
|
||||
currentParts := parseVersion(current)
|
||||
|
||||
for i := 0; i < len(latestParts) || i < len(currentParts); i++ {
|
||||
l, c := 0, 0
|
||||
if i < len(latestParts) {
|
||||
l = latestParts[i]
|
||||
}
|
||||
if i < len(currentParts) {
|
||||
c = currentParts[i]
|
||||
}
|
||||
if l > c {
|
||||
return true
|
||||
}
|
||||
if l < c {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseVersion strips the "v" prefix and splits "1.2.3" into [1, 2, 3].
|
||||
func parseVersion(v string) []int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.Split(v, ".")
|
||||
nums := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
nums = append(nums, n)
|
||||
}
|
||||
return nums
|
||||
}
|
||||
@@ -582,6 +582,15 @@
|
||||
</div>
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow-1"></div>
|
||||
<!-- Update Available Badge (Admin/Owner only) -->
|
||||
{{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}}
|
||||
<div class="nav-item d-none d-md-flex me-2">
|
||||
<a href="{{releaseURL}}" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Update verfügbar: {{latestVersion}} (aktuell: {{appVersion}})">
|
||||
<i class="ti ti-download" style="color: #fbbf24;"></i>
|
||||
<span style="color: #fbbf24;">Update {{latestVersion}}</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
<!-- Repository Link -->
|
||||
<div class="nav-item d-none d-md-flex me-2">
|
||||
<a href="https://git.techniverse.net/scriptos/keywarden" class="btn btn-header-modern btn-sm" target="_blank" rel="noopener noreferrer" title="Source code on Gitea">
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
<div class="card-body">
|
||||
{{with .SystemInfo}}
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Keywarden Version</div>
|
||||
<div class="datagrid-content">
|
||||
<span class="badge bg-blue-lt">{{appVersion}}</span>
|
||||
{{if updateAvailable}}
|
||||
<a href="{{releaseURL}}" target="_blank" rel="noopener noreferrer" class="badge bg-yellow-lt ms-1" title="Update verfügbar">
|
||||
<i class="ti ti-download"></i> {{latestVersion}} verfügbar
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Runtime Environment</div>
|
||||
<div class="datagrid-content">
|
||||
|
||||
Reference in New Issue
Block a user