All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
8.3 KiB
Go
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"`
|
|
}
|
|
|
|
// 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.
|
|
// Gitea package API — no /v1/ prefix
|
|
apiURL := fmt.Sprintf(
|
|
"%s/api/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
|
|
}
|