package kvm import ( "archive/zip" "bytes" "context" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "github.com/Masterminds/semver/v3" "github.com/gwatts/rootcerts" "github.com/rs/zerolog" ) type UpdateMetadata struct { AppVersion string `json:"appVersion"` AppUrl string `json:"appUrl"` AppHash string `json:"appHash"` SystemVersion string `json:"systemVersion"` SystemUrl string `json:"systemUrl"` SystemHash string `json:"systemHash"` } type LocalMetadata struct { AppVersion string `json:"appVersion"` SystemVersion string `json:"systemVersion"` } type RemoteMetadata struct { AppVersion string `json:"appVersion"` AppUrl string `json:"appUrl"` AppHash string `json:"appHash"` SystemUrl string `json:"systemUrl"` SystemHash string `json:"systemHash,omitempty"` SystemVersion string `json:"systemVersion"` } // UpdateStatus represents the current update status type UpdateStatus struct { Local *LocalMetadata `json:"local"` Remote *RemoteMetadata `json:"remote"` SystemUpdateAvailable bool `json:"systemUpdateAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"` // for backwards compatibility Error string `json:"error,omitempty"` } var UpdateGithubAppReleaseUrls = []string{ "https://api.github.com/repos/LuckfoxTECH/kvm/releases/latest", "https://api.github.com/repos/LuckfoxTECH/kvm_app/releases/latest", "https://api.github.com/repos/luckfox-eng29/kvm/releases/latest", "https://api.github.com/repos/luckfox-eng29/kvm_app/releases/latest", } var UpdateGiteeAppReleaseUrls = []string{ "https://gitee.com/api/v5/repos/LuckfoxTECH/kvm/releases/latest", "https://gitee.com/api/v5/repos/LuckfoxTECH/kvm_app/releases/latest", "https://gitee.com/api/v5/repos/luckfox-eng29/kvm/releases/latest", "https://gitee.com/api/v5/repos/luckfox-eng29/kvm_app/releases/latest", } var UpdateGithubSystemReleaseUrls = []string{ "https://api.github.com/repos/LuckfoxTECH/kvm_system/releases/latest", "https://api.github.com/repos/luckfox-eng29/kvm_system/releases/latest", } var UpdateGiteeSystemReleaseUrls = []string{ "https://gitee.com/api/v5/repos/LuckfoxTECH/kvm_system/releases/latest", "https://gitee.com/api/v5/repos/luckfox-eng29/kvm_system/releases/latest", } var UpdateGiteeSystemZipUrls = []string{ "https://gitee.com/LuckfoxTECH/kvm_system/archive/refs/tags/", "https://gitee.com/luckfox-eng29/kvm_system/archive/refs/tags/", } const cdnUpdateBaseURL = "https://cdn.picokvm.top/luckfox_picokvm_firmware/lastest/" var builtAppVersion = "0.1.2+dev" var updateSource = "github" var customUpdateBaseURL string const ( updateSourceGithub = "github" updateSourceGitee = "gitee" updateSourceCDN = "cdn" updateSourceCustom = "custom" ) func rpcSetUpdateSource(source string) error { switch source { case updateSourceGithub, updateSourceGitee, updateSourceCDN, updateSourceCustom: default: return fmt.Errorf("invalid update source: %s", source) } updateSource = source return nil } func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { appVersion, err = semver.NewVersion(builtAppVersion) if err != nil { return nil, nil, fmt.Errorf("invalid built-in app version: %w", err) } systemVersionBytes, err := os.ReadFile("/version") if err != nil { return nil, appVersion, fmt.Errorf("error reading system version: %w", err) } systemVersion, err = semver.NewVersion(strings.TrimSpace(string(systemVersionBytes))) if err != nil { return nil, appVersion, fmt.Errorf("invalid system version: %w", err) } return systemVersion, appVersion, nil } func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*RemoteMetadata, error) { if updateSource == updateSourceCDN || updateSource == updateSourceCustom { baseURL := cdnUpdateBaseURL if updateSource == updateSourceCustom { if strings.TrimSpace(customUpdateBaseURL) == "" { return nil, fmt.Errorf("custom update base URL is not set") } baseURL = customUpdateBaseURL } return fetchUpdateMetadataFromBaseURL(ctx, baseURL) } _, _ = deviceId, includePreRelease appVersionRemote, appURL, appSha256, err := fetchKvmAppLatestRelease(ctx) if err != nil { return nil, err } systemVersionRemote, systemZipURL, err := fetchKvmSystemLatestRelease(ctx) if err != nil { return nil, err } return &RemoteMetadata{ AppUrl: appURL, AppVersion: appVersionRemote, AppHash: appSha256, SystemUrl: systemZipURL, SystemVersion: systemVersionRemote, }, nil } func fetchKvmAppLatestRelease(ctx context.Context) (tag string, downloadURL string, sha256 string, err error) { apiURLs := UpdateGithubAppReleaseUrls fallbackToGithub := false if updateSource == updateSourceGitee { apiURLs = UpdateGiteeAppReleaseUrls fallbackToGithub = true } tryFetch := func(urls []string) (string, string, string, error) { var lastErr error for _, apiURL := range urls { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { lastErr = fmt.Errorf("failed to create release request for %s: %w", apiURL, err) continue } resp, err := http.DefaultClient.Do(req) if err != nil { lastErr = fmt.Errorf("failed to fetch release from %s: %w", apiURL, err) continue } output, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { lastErr = fmt.Errorf("failed to read release response from %s: %w", apiURL, readErr) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf( "failed to fetch release from %s: status %d: %s", apiURL, resp.StatusCode, strings.TrimSpace(string(output)), ) continue } var release struct { TagName string `json:"tag_name"` Assets []releaseAsset `json:"assets"` } if err := json.Unmarshal(output, &release); err != nil { lastErr = fmt.Errorf("failed to parse releases JSON from %s: %w", apiURL, err) continue } tag := strings.TrimSpace(release.TagName) if tag == "" { lastErr = fmt.Errorf("empty tag_name from %s", apiURL) continue } var downloadURL string var sha256 string if len(release.Assets) > 0 { downloadURL = release.Assets[0].BrowserDownloadURL sha256 = release.Assets[0].Digest } sha256 = strings.TrimPrefix(strings.TrimSpace(sha256), "sha256:") if strings.TrimSpace(downloadURL) == "" { lastErr = fmt.Errorf("empty app download url from %s", apiURL) continue } return tag, downloadURL, sha256, nil } if lastErr == nil { lastErr = fmt.Errorf("no app release API URLs configured") } return "", "", "", lastErr } var lastErr error tag, downloadURL, sha256, err = tryFetch(apiURLs) if err == nil { return tag, downloadURL, sha256, nil } lastErr = err if updateSource == updateSourceGitee && fallbackToGithub { tag, downloadURL, sha256, err = tryFetch(UpdateGithubAppReleaseUrls) if err == nil { downloadURL = strings.Replace(downloadURL, "github.com", "gitee.com", 1) return tag, downloadURL, sha256, nil } lastErr = fmt.Errorf("gitee app release fetch failed (%v); github fallback failed (%w)", lastErr, err) } return "", "", "", lastErr } type releaseAsset struct { BrowserDownloadURL string `json:"browser_download_url"` Name string `json:"name"` Digest string `json:"digest"` } func pickZipAssetURL(assets []releaseAsset) string { for _, a := range assets { u := strings.TrimSpace(a.BrowserDownloadURL) if u == "" { continue } name := strings.ToLower(strings.TrimSpace(a.Name)) if strings.HasSuffix(name, ".zip") || strings.HasSuffix(strings.ToLower(u), ".zip") { return u } } if len(assets) == 1 { return strings.TrimSpace(assets[0].BrowserDownloadURL) } return "" } func fetchKvmSystemLatestRelease(ctx context.Context) (tag string, zipURL string, err error) { apiURLs := UpdateGithubSystemReleaseUrls fallbackToGithub := false if updateSource == updateSourceGitee { apiURLs = UpdateGiteeSystemReleaseUrls fallbackToGithub = true } var lastErr error for _, apiURL := range apiURLs { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { lastErr = fmt.Errorf("error creating system release request: %w", err) continue } resp, err := http.DefaultClient.Do(req) if err != nil { lastErr = fmt.Errorf("error fetching system release: %w", err) continue } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { lastErr = fmt.Errorf("error reading system release response: %w", readErr) continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf( "unexpected status code fetching system release from %s: %d, %s", apiURL, resp.StatusCode, strings.TrimSpace(string(body)), ) continue } var release struct { TagName string `json:"tag_name"` ZipballURL string `json:"zipball_url"` Assets []releaseAsset `json:"assets"` } if err := json.Unmarshal(body, &release); err != nil { lastErr = fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err) continue } tag := strings.TrimSpace(release.TagName) if tag == "" { lastErr = fmt.Errorf("empty system tag_name from %s", apiURL) continue } if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" { return tag, strings.TrimSpace(u), nil } if strings.TrimSpace(release.ZipballURL) != "" { return tag, strings.TrimSpace(release.ZipballURL), nil } lastErr = fmt.Errorf("no usable system archive url in release response from %s", apiURL) continue } if lastErr == nil { lastErr = fmt.Errorf("no system release API URLs configured") } if updateSource == updateSourceGitee && fallbackToGithub { var githubErr error var githubTag string var githubZipURL string for i, apiURL := range UpdateGithubSystemReleaseUrls { githubTag, githubZipURL, githubErr = func(apiURL string) (string, string, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", fmt.Errorf("error creating system release request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", fmt.Errorf("error fetching system release: %w", err) } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return "", "", fmt.Errorf("error reading system release response: %w", readErr) } if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf( "unexpected status code fetching system release from %s: %d, %s", apiURL, resp.StatusCode, strings.TrimSpace(string(body)), ) } var release struct { TagName string `json:"tag_name"` ZipballURL string `json:"zipball_url"` Assets []releaseAsset `json:"assets"` } if err := json.Unmarshal(body, &release); err != nil { return "", "", fmt.Errorf("error parsing system release JSON from %s: %w", apiURL, err) } tag := strings.TrimSpace(release.TagName) if tag == "" { return "", "", fmt.Errorf("empty system tag_name from %s", apiURL) } if u := pickZipAssetURL(release.Assets); strings.TrimSpace(u) != "" { return tag, strings.TrimSpace(u), nil } if strings.TrimSpace(release.ZipballURL) != "" { return tag, strings.TrimSpace(release.ZipballURL), nil } return "", "", fmt.Errorf("no usable system archive url in release response from %s", apiURL) }(apiURL) if githubErr == nil && strings.TrimSpace(githubTag) != "" { _ = githubZipURL selectedZipURL := "" if i < len(UpdateGiteeSystemZipUrls) { selectedZipURL = UpdateGiteeSystemZipUrls[i] } else if len(UpdateGiteeSystemZipUrls) > 0 { selectedZipURL = UpdateGiteeSystemZipUrls[0] } if strings.TrimSpace(selectedZipURL) != "" { zipTag := strings.TrimSpace(githubTag) if v, parseErr := semver.NewVersion(zipTag); parseErr == nil && v != nil { zipTag = v.String() } else { zipTag = strings.TrimPrefix(zipTag, "v") zipTag = strings.TrimPrefix(zipTag, "V") } zipURL := strings.TrimRight(selectedZipURL, "/") + "/" + zipTag + ".zip" return githubTag, zipURL, nil } githubErr = fmt.Errorf("no gitee system zip urls configured") break } } return "", "", fmt.Errorf("gitee system release fetch failed (%v); github fallback failed (%w)", lastErr, githubErr) } return "", "", lastErr } func fetchUpdateMetadataFromBaseURL(ctx context.Context, baseURL string) (*RemoteMetadata, error) { baseURL = normalizeBaseURL(baseURL) versionURL, err := resolveURL(baseURL, "version.txt") if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "GET", versionURL, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } client := http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 30 * time.Second, TLSClientConfig: &tls.Config{ RootCAs: rootcerts.ServerCertPool(), }, }, } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("error fetching version.txt: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code fetching version.txt: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error reading version.txt: %w", err) } appVersion, systemVersion, err := parseVersionTxt(string(body)) if err != nil { return nil, err } appURL, err := resolveURL(baseURL, "kvm_app") if err != nil { return nil, err } appHash, err := fetchFirstSHA256FromBaseURL(ctx, baseURL, []string{"kvm_app.sha2565", "kvm_app.sha256"}) if err != nil { return nil, err } systemURL, err := resolveURL(baseURL, "update_system.zip") if err != nil { return nil, err } systemHash, err := fetchFirstSHA256FromBaseURL(ctx, baseURL, []string{"update_system.zip.sha2565", "update_system.zip.sha256"}) if err != nil { var urlErr error systemURL, urlErr = resolveURL(baseURL, "update_system.tar") if urlErr != nil { return nil, err } var hashErr error systemHash, hashErr = fetchFirstSHA256FromBaseURL(ctx, baseURL, []string{"update_system.tar.sha256"}) if hashErr != nil { return nil, err } } return &RemoteMetadata{ AppVersion: appVersion, AppUrl: appURL, AppHash: appHash, SystemVersion: systemVersion, SystemUrl: systemURL, SystemHash: systemHash, }, nil } func extractUpdateSystemTarFromZip(zipPath string, tarPath string) error { r, err := zip.OpenReader(zipPath) if err != nil { return fmt.Errorf("failed to open update_system.zip: %w", err) } defer r.Close() var tarFile *zip.File for _, f := range r.File { if strings.TrimSpace(f.Name) == "" { continue } if filepath.Base(f.Name) == "update_system.tar" { tarFile = f break } } if tarFile == nil { return fmt.Errorf("update_system.tar not found in %s", zipPath) } rc, err := tarFile.Open() if err != nil { return fmt.Errorf("failed to open update_system.tar in zip: %w", err) } defer rc.Close() tmpPath := tarPath + ".tmp" _ = os.Remove(tmpPath) out, err := os.Create(tmpPath) if err != nil { return fmt.Errorf("failed to create %s: %w", tmpPath, err) } _, copyErr := io.Copy(out, rc) closeErr := out.Close() if copyErr != nil { _ = os.Remove(tmpPath) return fmt.Errorf("failed to extract update_system.tar: %w", copyErr) } if closeErr != nil { _ = os.Remove(tmpPath) return fmt.Errorf("failed to close %s: %w", tmpPath, closeErr) } _ = os.Remove(tarPath) if err := os.Rename(tmpPath, tarPath); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("failed to move extracted tar: %w", err) } return nil } func fetchFirstSHA256FromBaseURL(ctx context.Context, baseURL string, candidates []string) (string, error) { var lastErr error for _, name := range candidates { u, err := resolveURL(baseURL, name) if err != nil { lastErr = err continue } hash, err := fetchSHA256FromURL(ctx, u) if err == nil { return hash, nil } lastErr = err } if lastErr == nil { lastErr = fmt.Errorf("no sha256 candidates provided") } return "", lastErr } func fetchSHA256FromURL(ctx context.Context, shaURL string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", shaURL, nil) if err != nil { return "", fmt.Errorf("error creating request: %w", err) } client := http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 30 * time.Second, TLSClientConfig: &tls.Config{ RootCAs: rootcerts.ServerCertPool(), }, }, } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("error fetching sha256 file: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("unexpected status code fetching sha256 file: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("error reading sha256 file: %w", err) } hash, err := parseSHA256Text(string(body)) if err != nil { return "", fmt.Errorf("invalid sha256 file content: %w", err) } return hash, nil } func parseSHA256Text(s string) (string, error) { re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`) match := re.FindStringSubmatch(s) if len(match) < 2 { return "", fmt.Errorf("no sha256 hash found") } hash := strings.ToLower(strings.TrimSpace(match[1])) hash = strings.TrimPrefix(hash, "sha256:") return hash, nil } func normalizeBaseURL(baseURL string) string { s := strings.TrimSpace(baseURL) if s == "" { return s } if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { s = "https://" + s } if !strings.HasSuffix(s, "/") { s += "/" } return s } func resolveURL(baseURL string, path string) (string, error) { u, err := url.Parse(baseURL) if err != nil { return "", fmt.Errorf("invalid base URL: %w", err) } ref, err := url.Parse(path) if err != nil { return "", fmt.Errorf("invalid URL path: %w", err) } return u.ResolveReference(ref).String(), nil } func parseVersionTxt(s string) (appVersion string, systemVersion string, err error) { reApp := regexp.MustCompile(`(?i)\bAppVersion\s*:\s*([0-9A-Za-z.\-+v]+)\b`) reSys := regexp.MustCompile(`(?i)\bSystemVersion\s*:\s*([0-9A-Za-z.\-+v]+)\b`) appMatch := reApp.FindStringSubmatch(s) sysMatch := reSys.FindStringSubmatch(s) if len(appMatch) < 2 || len(sysMatch) < 2 { return "", "", fmt.Errorf("invalid version.txt format") } appVersion = strings.TrimSpace(appMatch[1]) systemVersion = strings.TrimSpace(sysMatch[1]) return appVersion, systemVersion, nil } func shouldProxyUpdateDownloadURL(u *url.URL) bool { if u == nil { return false } host := strings.ToLower(strings.TrimSpace(u.Hostname())) if host == "" { return false } if host == "github.com" || host == "api.github.com" || host == "codeload.github.com" || host == "raw.githubusercontent.com" { return true } if strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".githubusercontent.com") || strings.HasSuffix(host, ".githubassets.com") { return true } return false } func applyUpdateDownloadProxyPrefix(rawURL string) string { if config == nil { return rawURL } proxy := strings.TrimSpace(config.UpdateDownloadProxy) if proxy == "" { return rawURL } proxy = strings.TrimRight(proxy, "/") + "/" if strings.HasPrefix(rawURL, proxy) { return rawURL } parsed, err := url.Parse(rawURL) if err != nil || parsed == nil { return rawURL } if parsed.Scheme != "http" && parsed.Scheme != "https" { return rawURL } if !shouldProxyUpdateDownloadURL(parsed) { return rawURL } return proxy + rawURL } func downloadFile( ctx context.Context, path string, url string, downloadProgress *float32, downloadSpeedBps *float32, ) error { //if _, err := os.Stat(path); err == nil { // if err := os.Remove(path); err != nil { // return fmt.Errorf("error removing existing file: %w", err) // } //} finalURL := applyUpdateDownloadProxyPrefix(url) otaLogger.Info().Str("path", path).Str("url", finalURL).Msg("downloading file") unverifiedPath := path + ".unverified" if _, err := os.Stat(unverifiedPath); err == nil { if err := os.Remove(unverifiedPath); err != nil { return fmt.Errorf("error removing existing unverified file: %w", err) } } file, err := os.Create(unverifiedPath) if err != nil { return fmt.Errorf("error creating file: %w", err) } defer file.Close() req, err := http.NewRequestWithContext(ctx, "GET", finalURL, nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } client := http.Client{ Timeout: 10 * time.Minute, Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 30 * time.Second, TLSClientConfig: &tls.Config{ RootCAs: rootcerts.ServerCertPool(), }, }, } resp, err := client.Do(req) if err != nil { return fmt.Errorf("error downloading file: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } totalSize := resp.ContentLength hasKnownSize := totalSize > 0 var written int64 var lastProgressBytes int64 lastProgressAt := time.Now() lastReportedProgress := float32(0) lastSpeedAt := time.Now() var lastSpeedBytes int64 if downloadProgress != nil { *downloadProgress = 0 } if downloadSpeedBps != nil { *downloadSpeedBps = 0 } if downloadProgress != nil || downloadSpeedBps != nil { triggerOTAStateUpdate() } buf := make([]byte, 32*1024) for { nr, er := resp.Body.Read(buf) if nr > 0 { nw, ew := file.Write(buf[0:nr]) if nw < nr { return fmt.Errorf("short write: %d < %d", nw, nr) } written += int64(nw) if ew != nil { return fmt.Errorf("error writing to file: %w", ew) } now := time.Now() speedUpdated := false progressUpdated := false if downloadSpeedBps != nil { dt := now.Sub(lastSpeedAt) if dt >= 1*time.Second { seconds := float32(dt.Seconds()) if seconds <= 0 { *downloadSpeedBps = 0 } else { *downloadSpeedBps = float32(written-lastSpeedBytes) / seconds } lastSpeedAt = now lastSpeedBytes = written speedUpdated = true } } if hasKnownSize && downloadProgress != nil { progress := float32(written) / float32(totalSize) if progress-lastReportedProgress >= 0.001 || now.Sub(lastProgressAt) >= 1*time.Second { lastReportedProgress = progress *downloadProgress = lastReportedProgress lastProgressAt = now progressUpdated = true } } if !hasKnownSize && downloadProgress != nil { if *downloadProgress <= 0 { *downloadProgress = 0.01 lastProgressBytes = written progressUpdated = true } else if written-lastProgressBytes >= 1024*1024 { next := *downloadProgress + 0.01 if next > 0.99 { next = 0.99 } if next-*downloadProgress >= 0.01 { *downloadProgress = next lastProgressBytes = written progressUpdated = true } } } if speedUpdated || progressUpdated { triggerOTAStateUpdate() } } if er != nil { if er == io.EOF { break } return fmt.Errorf("error reading response body: %w", er) } } if hasKnownSize && written != totalSize { return fmt.Errorf("incomplete download: wrote %d bytes, expected %d bytes", written, totalSize) } if downloadProgress != nil && !hasKnownSize { *downloadProgress = 1 if downloadSpeedBps != nil { *downloadSpeedBps = 0 } triggerOTAStateUpdate() } if downloadSpeedBps != nil && hasKnownSize { *downloadSpeedBps = 0 triggerOTAStateUpdate() } file.Close() // Flush filesystem buffers to ensure all data is written to disk err = exec.Command("sync").Run() if err != nil { otaLogger.Warn().Err(err).Msg("Failed to flush filesystem buffers") } // Clear the filesystem caches to force a read from disk err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644) if err != nil { otaLogger.Warn().Err(err).Msg("Failed to clear filesystem caches") } // without check //if err := os.Rename(unverifiedPath, path); err != nil { // return fmt.Errorf("error renaming file: %w", err) //} //if err := os.Chmod(path, 0755); err != nil { // return fmt.Errorf("error making file executable: %w", err) //} return nil } func prepareSystemUpdateTarFromKvmSystemZip( ctx context.Context, zipURL string, outputTarPath string, downloadProgress *float32, downloadSpeedBps *float32, verificationProgress *float32, scopedLogger *zerolog.Logger, ) error { if scopedLogger == nil { scopedLogger = otaLogger } baseDir := "/userdata/picokvm" workDir := filepath.Join(baseDir, "kvm_system_work") extractDir := filepath.Join(workDir, "extract") zipPath := filepath.Join(workDir, "master.zip") if err := os.MkdirAll(workDir, 0755); err != nil { return fmt.Errorf("error creating work dir: %w", err) } if err := os.RemoveAll(extractDir); err != nil { return fmt.Errorf("error cleaning extract dir: %w", err) } if err := os.MkdirAll(extractDir, 0755); err != nil { return fmt.Errorf("error creating extract dir: %w", err) } if verificationProgress != nil { *verificationProgress = 0 triggerOTAStateUpdate() } maxAttempts := 3 var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { if downloadProgress != nil { *downloadProgress = 0 } if downloadSpeedBps != nil { *downloadSpeedBps = 0 } if downloadProgress != nil || downloadSpeedBps != nil { triggerOTAStateUpdate() } if err := downloadFile(ctx, zipPath, zipURL, downloadProgress, downloadSpeedBps); err != nil { lastErr = err } else { zipUnverifiedPath := zipPath + ".unverified" if _, err := os.Stat(zipUnverifiedPath); err != nil { lastErr = fmt.Errorf("downloaded zip not found: %s: %w", zipUnverifiedPath, err) } else { if err := unzipArchive(zipUnverifiedPath, extractDir); err != nil { lastErr = err } else { lastErr = nil break } } } _ = os.Remove(zipPath + ".unverified") _ = os.RemoveAll(extractDir) _ = os.MkdirAll(extractDir, 0755) if attempt < maxAttempts { time.Sleep(time.Duration(attempt*2) * time.Second) } } if lastErr != nil { return lastErr } extractedRoot := filepath.Join(extractDir, "kvm_system-master") if _, err := os.Stat(extractedRoot); err != nil { entries, readErr := os.ReadDir(extractDir) if readErr != nil { return fmt.Errorf("error reading extracted dir: %w", readErr) } found := "" for _, entry := range entries { if entry.IsDir() { found = filepath.Join(extractDir, entry.Name()) break } } if found == "" { return fmt.Errorf("unable to find extracted root dir in %s", extractDir) } extractedRoot = found } scriptPath := filepath.Join(extractedRoot, "split_and_check_md5.sh") if _, err := os.Stat(scriptPath); err != nil { return fmt.Errorf("split_and_check_md5.sh not found: %w", err) } if err := os.Chmod(scriptPath, 0755); err != nil { return fmt.Errorf("error chmod split_and_check_md5.sh: %w", err) } var out bytes.Buffer cmd := exec.Command(scriptPath, "merge", "update_system.tar") cmd.Dir = extractedRoot cmd.Stdout = &out cmd.Stderr = &out if err := cmd.Run(); err != nil { out.Reset() cmd2 := exec.Command("/bin/sh", scriptPath, "merge", "update_system.tar") cmd2.Dir = extractedRoot cmd2.Stdout = &out cmd2.Stderr = &out if err2 := cmd2.Run(); err2 != nil { return fmt.Errorf("error merging split system tar: %w / %w\nOutput: %s", err, err2, out.String()) } } tarSourcePath := filepath.Join(extractedRoot, "update_system.tar") if _, err := os.Stat(tarSourcePath); err != nil { return fmt.Errorf("merged tar not found: %s: %w\nOutput: %s", tarSourcePath, err, out.String()) } if err := os.RemoveAll(outputTarPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("error removing existing system tar: %w", err) } if err := os.Rename(tarSourcePath, outputTarPath); err != nil { return fmt.Errorf("error moving merged tar into place: %w", err) } if verificationProgress != nil { *verificationProgress = 1 triggerOTAStateUpdate() } if err := os.RemoveAll(extractDir); err != nil { scopedLogger.Warn().Err(err).Str("path", extractDir).Msg("Failed to cleanup extracted system zip") } zipUnverifiedPath := zipPath + ".unverified" if err := os.Remove(zipUnverifiedPath); err != nil { scopedLogger.Warn().Err(err).Str("path", zipUnverifiedPath).Msg("Failed to cleanup system zip") } return nil } func unzipArchive(zipPath string, destDir string) error { reader, err := zip.OpenReader(zipPath) if err != nil { return fmt.Errorf("error opening zip: %w", err) } defer reader.Close() destClean := filepath.Clean(destDir) + string(os.PathSeparator) for _, file := range reader.File { targetPath := filepath.Join(destDir, file.Name) cleanTargetPath := filepath.Clean(targetPath) if !strings.HasPrefix(cleanTargetPath, destClean) { return fmt.Errorf("invalid zip path: %s", file.Name) } if file.FileInfo().IsDir() { if err := os.MkdirAll(cleanTargetPath, 0755); err != nil { return fmt.Errorf("error creating dir: %w", err) } continue } if err := os.MkdirAll(filepath.Dir(cleanTargetPath), 0755); err != nil { return fmt.Errorf("error creating dir: %w", err) } rc, err := file.Open() if err != nil { return fmt.Errorf("error opening zipped file: %w", err) } outFile, err := os.OpenFile(cleanTargetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) if err != nil { rc.Close() return fmt.Errorf("error creating file: %w", err) } _, copyErr := io.Copy(outFile, rc) closeErr := outFile.Close() rcErr := rc.Close() if copyErr != nil { return fmt.Errorf("error extracting file: %w", copyErr) } if closeErr != nil { return fmt.Errorf("error closing extracted file: %w", closeErr) } if rcErr != nil { return fmt.Errorf("error closing zip entry: %w", rcErr) } } return nil } func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error { if scopedLogger == nil { scopedLogger = otaLogger } unverifiedPath := path + ".unverified" if strings.TrimSpace(expectedHash) == "" { if err := os.Rename(unverifiedPath, path); err != nil { return fmt.Errorf("error renaming file: %w", err) } if err := os.Chmod(path, 0755); err != nil { return fmt.Errorf("error making file executable: %w", err) } return nil } fileToHash, err := os.Open(unverifiedPath) if err != nil { return fmt.Errorf("error opening file for hashing: %w", err) } defer fileToHash.Close() hash := sha256.New() fileInfo, err := fileToHash.Stat() if err != nil { return fmt.Errorf("error getting file info: %w", err) } totalSize := fileInfo.Size() buf := make([]byte, 32*1024) verified := int64(0) for { nr, er := fileToHash.Read(buf) if nr > 0 { nw, ew := hash.Write(buf[0:nr]) if nw < nr { return fmt.Errorf("short write: %d < %d", nw, nr) } verified += int64(nw) if ew != nil { return fmt.Errorf("error writing to hash: %w", ew) } progress := float32(verified) / float32(totalSize) if progress-*verifyProgress >= 0.01 { *verifyProgress = progress triggerOTAStateUpdate() } } if er != nil { if er == io.EOF { break } return fmt.Errorf("error reading file: %w", er) } } hashSum := hash.Sum(nil) scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of") if hex.EncodeToString(hashSum) != expectedHash { return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash) } if err := os.Rename(unverifiedPath, path); err != nil { return fmt.Errorf("error renaming file: %w", err) } if err := os.Chmod(path, 0755); err != nil { return fmt.Errorf("error making file executable: %w", err) } return nil } type OTAState struct { Updating bool `json:"updating"` Error string `json:"error,omitempty"` MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` AppUpdatePending bool `json:"appUpdatePending"` SystemUpdatePending bool `json:"systemUpdatePending"` AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar AppDownloadSpeedBps float32 `json:"appDownloadSpeedBps"` AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar SystemDownloadSpeedBps float32 `json:"systemDownloadSpeedBps"` SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"` SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` } var otaState = OTAState{} func triggerOTAStateUpdate() { go func() { if currentSession == nil { logger.Info().Msg("No active RPC session, skipping update state update") return } writeJSONRPCEvent("otaState", otaState, currentSession) }() } func cleanupUpdateTempFiles(logger *zerolog.Logger) { paths := []string{ "/userdata/picokvm/bin/kvm_app.unverified", "/userdata/picokvm/update_system.tar.unverified", "/userdata/picokvm/update_system.tar", "/userdata/picokvm/kvm_system_work", } for _, p := range paths { if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) { if logger != nil { logger.Warn().Err(err).Str("path", p).Msg("failed to cleanup temp update file") } else { otaLogger.Warn().Err(err).Str("path", p).Msg("failed to cleanup temp update file") } } } } func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error { scopedLogger := otaLogger.With(). Str("deviceId", deviceId). Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)). Logger() scopedLogger.Info().Msg("Trying to update...") if otaState.Updating { return fmt.Errorf("update already in progress") } cleanupUpdateTempFiles(&scopedLogger) otaState = OTAState{ Updating: true, } triggerOTAStateUpdate() defer func() { otaState.Updating = false triggerOTAStateUpdate() }() updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease) if err != nil { otaState.Error = fmt.Sprintf("Error checking for updates: %v", err) scopedLogger.Error().Err(err).Msg("Error checking for updates") return fmt.Errorf("error checking for updates: %w", err) } now := time.Now() otaState.MetadataFetchedAt = &now otaState.AppUpdatePending = updateStatus.AppUpdateAvailable otaState.SystemUpdatePending = updateStatus.SystemUpdateAvailable triggerOTAStateUpdate() local := updateStatus.Local remote := updateStatus.Remote appUpdateAvailable := updateStatus.AppUpdateAvailable systemUpdateAvailable := updateStatus.SystemUpdateAvailable rebootNeeded := false if appUpdateAvailable { scopedLogger.Info(). Str("local", local.AppVersion). Str("remote", remote.AppVersion). Msg("App update available") err := downloadFile( ctx, "/userdata/picokvm/bin/kvm_app", remote.AppUrl, &otaState.AppDownloadProgress, &otaState.AppDownloadSpeedBps, ) if err != nil { otaState.Error = fmt.Sprintf("Error downloading app update: %v", err) scopedLogger.Error().Err(err).Msg("Error downloading app update") triggerOTAStateUpdate() return err } downloadFinished := time.Now() otaState.AppDownloadFinishedAt = &downloadFinished otaState.AppDownloadProgress = 1 triggerOTAStateUpdate() err = verifyFile( "/userdata/picokvm/bin/kvm_app", remote.AppHash, &otaState.AppVerificationProgress, &scopedLogger, ) if err != nil { otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err) scopedLogger.Error().Err(err).Msg("Error verifying app update hash") triggerOTAStateUpdate() return err } verifyFinished := time.Now() otaState.AppVerifiedAt = &verifyFinished otaState.AppVerificationProgress = 1 otaState.AppUpdatedAt = &verifyFinished otaState.AppUpdateProgress = 1 triggerOTAStateUpdate() scopedLogger.Info().Msg("App update downloaded") rebootNeeded = true } else { scopedLogger.Info().Msg("App is up to date") } if systemUpdateAvailable { scopedLogger.Info(). Str("local", local.SystemVersion). Str("remote", remote.SystemVersion). Msg("System update available") systemTarPath := "/userdata/picokvm/update_system.tar" if updateSource == updateSourceGithub || updateSource == updateSourceGitee { err := prepareSystemUpdateTarFromKvmSystemZip( ctx, remote.SystemUrl, systemTarPath, &otaState.SystemDownloadProgress, &otaState.SystemDownloadSpeedBps, &otaState.SystemVerificationProgress, &scopedLogger, ) if err != nil { otaState.Error = fmt.Sprintf("Error preparing system update: %v", err) scopedLogger.Error().Err(err).Msg("Error preparing system update") triggerOTAStateUpdate() return err } } else { systemZipPath := "/userdata/picokvm/update_system.zip" err := downloadFile( ctx, systemZipPath, remote.SystemUrl, &otaState.SystemDownloadProgress, &otaState.SystemDownloadSpeedBps, ) if err != nil { otaState.Error = fmt.Sprintf("Error downloading system update: %v", err) scopedLogger.Error().Err(err).Msg("Error downloading system update") triggerOTAStateUpdate() return err } err = verifyFile(systemZipPath, remote.SystemHash, &otaState.SystemVerificationProgress, &scopedLogger) if err != nil { otaState.Error = fmt.Sprintf("Error preparing system update archive: %v", err) scopedLogger.Error().Err(err).Msg("Error preparing system update archive") triggerOTAStateUpdate() return err } if err := extractUpdateSystemTarFromZip(systemZipPath, systemTarPath); err != nil { otaState.Error = fmt.Sprintf("Error extracting system update tar: %v", err) scopedLogger.Error().Err(err).Msg("Error extracting system update tar") triggerOTAStateUpdate() return err } } downloadFinished := time.Now() otaState.SystemDownloadFinishedAt = &downloadFinished otaState.SystemDownloadProgress = 1 triggerOTAStateUpdate() scopedLogger.Info().Msg("System update downloaded") verifyFinished := time.Now() otaState.SystemVerifiedAt = &verifyFinished otaState.SystemVerificationProgress = 1 triggerOTAStateUpdate() scopedLogger.Info().Msg("Starting rk_ota command") if _, statErr := os.Stat(systemTarPath); statErr != nil { otaState.Error = fmt.Sprintf("System update archive not found: %s (%v)", systemTarPath, statErr) scopedLogger.Error().Err(statErr).Str("path", systemTarPath).Msg("System update archive missing") triggerOTAStateUpdate() return fmt.Errorf("system update archive not found: %s: %w", systemTarPath, statErr) } cmd := exec.Command("rk_ota", "--misc=update", "--tar_path="+systemTarPath, "--save_dir=/userdata/picokvm/ota_save", "--partition=all") var b bytes.Buffer cmd.Stdout = &b cmd.Stderr = &b err = cmd.Start() if err != nil { otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err) scopedLogger.Error().Err(err).Msg("Error starting rk_ota command") triggerOTAStateUpdate() return fmt.Errorf("error starting rk_ota command: %w", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { ticker := time.NewTicker(1800 * time.Millisecond) defer ticker.Stop() for { select { case <-ticker.C: if otaState.SystemUpdateProgress >= 0.99 { return } otaState.SystemUpdateProgress += 0.01 if otaState.SystemUpdateProgress > 0.99 { otaState.SystemUpdateProgress = 0.99 } triggerOTAStateUpdate() case <-ctx.Done(): return } } }() err = cmd.Wait() cancel() output := b.String() if err != nil { otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output) scopedLogger.Error(). Err(err). Str("output", output). Int("exitCode", cmd.ProcessState.ExitCode()). Msg("Error executing rk_ota command") triggerOTAStateUpdate() return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output) } scopedLogger.Info().Str("output", output).Msg("rk_ota success") updatedAt := time.Now() otaState.SystemUpdateProgress = 1 otaState.SystemUpdatedAt = &updatedAt triggerOTAStateUpdate() rebootNeeded = true } else { scopedLogger.Info().Msg("System is up to date") } if rebootNeeded { configPath := "/userdata/kvm_config.json" if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { scopedLogger.Warn().Err(err).Str("path", configPath).Msg("failed to delete config before reboot") } else { scopedLogger.Info().Str("path", configPath).Msg("deleted config before reboot") } scopedLogger.Info().Msg("System Rebooting in 10s") time.Sleep(10 * time.Second) cmd := exec.Command("reboot") err := cmd.Start() if err != nil { otaState.Error = fmt.Sprintf("Failed to start reboot: %v", err) scopedLogger.Error().Err(err).Msg("Failed to start reboot") return fmt.Errorf("failed to start reboot: %w", err) } else { os.Exit(0) } } return nil } func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) { updateStatus := &UpdateStatus{} // Get local versions systemVersionLocal, appVersionLocal, err := GetLocalVersion() if err != nil { return updateStatus, fmt.Errorf("error getting local version: %w", err) } updateStatus.Local = &LocalMetadata{ AppVersion: appVersionLocal.String(), SystemVersion: systemVersionLocal.String(), } // Get remote metadata remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease) if err != nil { return updateStatus, fmt.Errorf("error checking for updates: %w", err) } updateStatus.Remote = remoteMetadata // Get remote versions systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) if err != nil { return updateStatus, fmt.Errorf("error parsing remote system version: %w", err) } appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) if err != nil { return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) } updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal) updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal) // Handle pre-release updates isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" if isRemoteSystemPreRelease && !includePreRelease { updateStatus.SystemUpdateAvailable = false } if isRemoteAppPreRelease && !includePreRelease { updateStatus.AppUpdateAvailable = false } return updateStatus, nil } func IsUpdatePending() bool { return otaState.Updating } // make sure our current a/b partition is set as default func confirmCurrentSystem() { output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput() if err != nil { logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") } }