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 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 response: %w", err) } slog.Info("release API response", "status", resp.StatusCode, "bodyLen", len(body)) if resp.StatusCode != http.StatusOK { // No releases yet — not an error, just nothing to update to slog.Info("no releases found", "status", resp.StatusCode) return info, nil } var release giteaRelease if err := json.Unmarshal(body, &release); err != nil { return nil, fmt.Errorf("decode release: %w", err) } if release.TagName == "" { slog.Info("release has no tag") 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. // Use the stripped version (no "v" prefix) because CI uploads packages under "0.8.3" not "v0.8.3" tagVersion := latestVer 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 }