diff --git a/Dockerfile b/Dockerfile index e1f42a4..647e3c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 677144c..3d83236 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/keywarden/main.go b/cmd/keywarden/main.go index 2a8c327..cc3c673 100644 --- a/cmd/keywarden/main.go +++ b/cmd/keywarden/main.go @@ -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") diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 5d32637..b495722 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index fb1a78f..2c84438 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/contributing.md b/docs/contributing.md index 12769b6..58609e7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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) diff --git a/docs/deployment.md b/docs/deployment.md index bfa4b33..2e4c077 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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`: diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 99c965c..3bec443 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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") diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..2e4003e --- /dev/null +++ b/internal/updater/updater.go @@ -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 +} diff --git a/web/templates/layout/base.html b/web/templates/layout/base.html index 02c5ee2..9174d86 100644 --- a/web/templates/layout/base.html +++ b/web/templates/layout/base.html @@ -582,6 +582,15 @@
+ + {{with .User}}{{if and (updateAvailable) (or (eq .Role "admin") (eq .Role "owner"))}} + + {{end}}{{end}}