Files
kvm/ota.go
luckfox-eng29 6f426e8999 Update App version to 0.1.2
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
2026-03-22 23:06:10 +08:00

1538 lines
43 KiB
Go

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")
}
}