wraith/internal/updater/service.go
Vantz Stockwell 4af90bb80d
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s
fix: switch updater from packages API to releases API for version check
Gitea's generic package list endpoint wasn't returning 200. Switched to
/api/v1/repos/{owner}/{repo}/releases/latest which is the standard
Gitea releases API. Download URLs still use the packages registry.
Repo is now public — no auth token needed for version checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:26:31 -04:00

308 lines
8.3 KiB
Go

package updater
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// UpdateInfo describes the result of checking for an available update.
type UpdateInfo struct {
Available bool `json:"available"`
CurrentVer string `json:"currentVersion"`
LatestVer string `json:"latestVersion"`
DownloadURL string `json:"downloadUrl"`
SHA256 string `json:"sha256"`
}
// UpdateService checks a Gitea generic-package registry for new versions
// and can download + launch an installer to apply the update.
type UpdateService struct {
currentVersion string
baseURL string // e.g. https://git.command.vigilcyber.com
owner string // e.g. vstockwell
pkg string // e.g. wraith
httpClient *http.Client
}
// CurrentVersion returns the build-time version string.
func (u *UpdateService) CurrentVersion() string {
return u.currentVersion
}
// NewUpdateService creates an UpdateService that compares against the
// supplied currentVersion (semver without "v" prefix, e.g. "0.2.0").
func NewUpdateService(currentVersion string) *UpdateService {
return &UpdateService{
currentVersion: strings.TrimPrefix(currentVersion, "v"),
baseURL: "https://git.command.vigilcyber.com",
owner: "vstockwell",
pkg: "wraith",
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// giteaPackageVersion is the subset of the Gitea API response we need.
type giteaPackageVersion struct {
Version string `json:"version"`
}
// giteaRelease is the subset of the Gitea releases API response we need.
type giteaRelease struct {
TagName string `json:"tag_name"`
}
// versionJSON is the schema of the version.json file in the package.
type versionJSON struct {
Version string `json:"version"`
SHA256 string `json:"sha256"`
DownloadURL string `json:"downloadUrl"`
}
// CheckForUpdate queries the Gitea package API for the latest version and
// compares it with the current version using semver comparison.
func (u *UpdateService) CheckForUpdate() (*UpdateInfo, error) {
info := &UpdateInfo{
CurrentVer: u.currentVersion,
}
// Fetch latest package version from the Gitea API.
// Use Gitea releases API to find the latest release tag
apiURL := fmt.Sprintf(
"%s/api/v1/repos/%s/%s/releases/latest",
u.baseURL, u.owner, u.pkg,
)
slog.Info("checking for updates", "url", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch latest release: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read release API response: %w", err)
}
slog.Info("release API response", "status", resp.StatusCode, "body", string(body)[:min(len(body), 500)])
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d from releases API: %s", resp.StatusCode, string(body))
}
var release giteaRelease
if err := json.Unmarshal(body, &release); err != nil {
return nil, fmt.Errorf("decode release: %w", err)
}
if release.TagName == "" {
slog.Info("no releases found")
return info, nil
}
latestVer := strings.TrimPrefix(release.TagName, "v")
info.LatestVer = latestVer
slog.Info("latest release", "tag", release.TagName, "version", latestVer, "current", u.currentVersion)
cmp := CompareVersions(u.currentVersion, latestVer)
if cmp >= 0 {
slog.Info("already up to date", "current", u.currentVersion, "latest", latestVer)
return info, nil
}
// Newer version is available — fetch version.json for SHA256 + download URL.
tagVersion := release.TagName // e.g. "v0.6.0" or "0.6.0"
versionInfoURL := fmt.Sprintf(
"%s/api/packages/%s/generic/%s/%s/version.json",
u.baseURL, u.owner, u.pkg, tagVersion,
)
vInfo, err := u.fetchVersionJSON(versionInfoURL)
if err != nil {
// Non-fatal: we still know the version is available, just no SHA256.
slog.Warn("could not fetch version.json", "error", err)
}
info.Available = true
if vInfo != nil {
info.SHA256 = vInfo.SHA256
if vInfo.DownloadURL != "" {
info.DownloadURL = vInfo.DownloadURL
}
}
if info.DownloadURL == "" {
info.DownloadURL = fmt.Sprintf(
"%s/api/packages/%s/generic/%s/%s/wraith-%s-setup.exe",
u.baseURL, u.owner, u.pkg, tagVersion, tagVersion,
)
}
slog.Info("update available", "current", u.currentVersion, "latest", latestVer)
return info, nil
}
// fetchVersionJSON fetches and decodes a version.json file from the given URL.
func (u *UpdateService) fetchVersionJSON(url string) (*versionJSON, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token := os.Getenv("GIT_TOKEN"); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var v versionJSON
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return nil, err
}
return &v, nil
}
// DownloadUpdate downloads the installer for the given version to a temporary
// file and returns its path. If the UpdateInfo includes a SHA256 hash the
// download is verified against it.
func (u *UpdateService) DownloadUpdate(info *UpdateInfo) (string, error) {
if info.DownloadURL == "" {
return "", fmt.Errorf("no download URL")
}
slog.Info("downloading update", "url", info.DownloadURL)
req, err := http.NewRequest("GET", info.DownloadURL, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
if token := os.Getenv("GIT_TOKEN"); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("download installer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d downloading installer", resp.StatusCode)
}
// Write to a temp file.
tmpDir := os.TempDir()
filename := fmt.Sprintf("wraith-%s-setup.exe", info.LatestVer)
destPath := filepath.Join(tmpDir, filename)
f, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer f.Close()
hasher := sha256.New()
writer := io.MultiWriter(f, hasher)
if _, err := io.Copy(writer, resp.Body); err != nil {
os.Remove(destPath)
return "", fmt.Errorf("write installer: %w", err)
}
// Verify SHA256 if provided.
if info.SHA256 != "" {
got := hex.EncodeToString(hasher.Sum(nil))
if !strings.EqualFold(got, info.SHA256) {
os.Remove(destPath)
return "", fmt.Errorf("SHA256 mismatch: expected %s, got %s", info.SHA256, got)
}
slog.Info("SHA256 verified", "hash", got)
}
slog.Info("update downloaded", "path", destPath)
return destPath, nil
}
// ApplyUpdate launches the downloaded installer and signals the caller to
// exit. On Windows it starts the exe; on other platforms it logs a message.
func (u *UpdateService) ApplyUpdate(installerPath string) error {
slog.Info("applying update", "installer", installerPath)
if runtime.GOOS == "windows" {
cmd := exec.Command(installerPath, "/SILENT")
if err := cmd.Start(); err != nil {
return fmt.Errorf("launch installer: %w", err)
}
// The caller (frontend) should exit the app after this returns.
return nil
}
// Non-Windows: nothing to do automatically — inform the user.
slog.Info("update downloaded — please run the installer manually", "path", installerPath)
return nil
}
// CompareVersions compares two semver strings (major.minor.patch).
// Returns -1 if a < b, 0 if equal, 1 if a > b.
// Strings are trimmed of a leading "v" prefix.
func CompareVersions(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
aParts := parseSemver(a)
bParts := parseSemver(b)
for i := 0; i < 3; i++ {
if aParts[i] < bParts[i] {
return -1
}
if aParts[i] > bParts[i] {
return 1
}
}
return 0
}
// parseSemver splits a version string into [major, minor, patch].
// Missing components default to 0.
func parseSemver(v string) [3]int {
var parts [3]int
segments := strings.SplitN(v, ".", 3)
for i, s := range segments {
if i >= 3 {
break
}
n, _ := strconv.Atoi(s)
parts[i] = n
}
return parts
}