All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m3s
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>
307 lines
8.3 KiB
Go
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
|
|
}
|