wraith/internal/updater/service.go
Vantz Stockwell c782fcc2c3
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m3s
fix: add updater API response logging to diagnose update check failures
Logs the raw API response body and status from Gitea package API,
plus parsed version count and current version comparison. This will
show exactly why updates aren't being detected.

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

307 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"`
}
// 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.
apiURL := fmt.Sprintf(
"%s/api/v1/packages/%s/generic/%s?limit=1&sort=created_at&direction=desc",
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)
}
// Use GIT_TOKEN for private registries, if available.
if token := os.Getenv("GIT_TOKEN"); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch package versions: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read package API response: %w", err)
}
slog.Info("package 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 package API: %s", resp.StatusCode, string(body))
}
var versions []giteaPackageVersion
if err := json.Unmarshal(body, &versions); err != nil {
return nil, fmt.Errorf("decode package versions: %w", err)
}
slog.Info("parsed versions", "count", len(versions), "currentVersion", u.currentVersion)
if len(versions) == 0 {
slog.Info("no package versions found")
return info, nil
}
latestVer := strings.TrimPrefix(versions[0].Version, "v")
info.LatestVer = latestVer
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.
versionInfoURL := fmt.Sprintf(
"%s/api/packages/%s/generic/%s/%s/version.json",
u.baseURL, u.owner, u.pkg, versions[0].Version,
)
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, versions[0].Version, versions[0].Version,
)
}
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
}