- 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)
194 lines
4.5 KiB
Go
194 lines
4.5 KiB
Go
// 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
|
|
}
|