Files
keywarden/internal/updater/updater.go

218 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
// Public releases page URL
ReleasesPageURL = "https://git.techniverse.net/scriptos/keywarden/releases"
// 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").
// Pre-release versions (e.g. "v1.0.0-alpha") are considered older than the
// same version without a suffix (e.g. "v1.0.0").
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
}
}
// Numeric parts are equal a stable release is newer than a pre-release
// of the same version (e.g. v0.3.0 > v0.3.0-alpha).
if hasPreRelease(current) && !hasPreRelease(latest) {
return true
}
return false
}
// parseVersion strips the "v" prefix and pre-release suffixes (e.g. "-alpha")
// then splits "1.2.3" into [1, 2, 3].
// A pre-release version is indicated by returning isPreRelease = true.
func parseVersion(v string) []int {
v = strings.TrimPrefix(v, "v")
// Strip pre-release suffix (e.g. "-alpha", "-beta.1", "-rc1")
if idx := strings.IndexByte(v, '-'); idx >= 0 {
v = v[:idx]
}
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
}
// hasPreRelease returns true when the version string contains a pre-release
// suffix (e.g. "-alpha", "-beta", "-rc1").
func hasPreRelease(v string) bool {
v = strings.TrimPrefix(v, "v")
return strings.ContainsRune(v, '-')
}