218 lines
5.4 KiB
Go
218 lines
5.4 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"
|
||
// 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, '-')
|
||
}
|