package updater import ( "archive/zip" "compress/gzip" "context" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "sync" "time" mihomoHttp "github.com/metacubex/mihomo/component/http" C "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/constant/features" "github.com/metacubex/mihomo/log" ) const ( baseReleaseURL = "https://github.com/MetaCubeX/mihomo/releases/latest/download/" versionReleaseURL = "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt" baseAlphaURL = "https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/" versionAlphaURL = "https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt" // MaxPackageFileSize is a maximum package file length in bytes. The largest // package whose size is limited by this constant currently has the size of // approximately 32 MiB. MaxPackageFileSize = 32 * 1024 * 1024 ) // CoreUpdater is the mihomo updater. // modify from https://github.com/AdguardTeam/AdGuardHome/blob/595484e0b3fb4c457f9bb727a6b94faa78a66c5f/internal/updater/updater.go type CoreUpdater struct { mu sync.Mutex } var DefaultCoreUpdater = CoreUpdater{} func (u *CoreUpdater) CoreBaseName() string { switch runtime.GOARCH { case "arm": // mihomo-linux-armv5 return fmt.Sprintf("mihomo-%s-%sv%s", runtime.GOOS, runtime.GOARCH, features.GOARM) case "arm64": if runtime.GOOS == "android" { // mihomo-android-arm64-v8 return fmt.Sprintf("mihomo-%s-%s-v8", runtime.GOOS, runtime.GOARCH) } else { // mihomo-linux-arm64 return fmt.Sprintf("mihomo-%s-%s", runtime.GOOS, runtime.GOARCH) } case "mips", "mipsle": // mihomo-linux-mips-hardfloat return fmt.Sprintf("mihomo-%s-%s-%s", runtime.GOOS, runtime.GOARCH, features.GOMIPS) case "amd64": // mihomo-linux-amd64-v1 return fmt.Sprintf("mihomo-%s-%s-%s", runtime.GOOS, runtime.GOARCH, features.GOAMD64) default: // mihomo-linux-386 // mihomo-linux-mips64 // mihomo-linux-riscv64 // mihomo-linux-s390x return fmt.Sprintf("mihomo-%s-%s", runtime.GOOS, runtime.GOARCH) } } func (u *CoreUpdater) Update(currentExePath string) (err error) { u.mu.Lock() defer u.mu.Unlock() _, err = os.Stat(currentExePath) if err != nil { return fmt.Errorf("check currentExePath %q: %w", currentExePath, err) } baseURL := baseAlphaURL versionURL := versionAlphaURL if !strings.HasPrefix(C.Version, "alpha") { baseURL = baseReleaseURL versionURL = versionReleaseURL } latestVersion, err := u.getLatestVersion(versionURL) if err != nil { return fmt.Errorf("get latest version: %w", err) } log.Infoln("current version %s, latest version %s", C.Version, latestVersion) if latestVersion == C.Version { // don't change this output, some downstream dependencies on the upgrader's output fields return fmt.Errorf("update error: already using latest version %s", C.Version) } defer func() { if err != nil { log.Errorln("updater: failed: %v", err) } else { log.Infoln("updater: finished") } }() // ---- prepare ---- mihomoBaseName := u.CoreBaseName() packageName := mihomoBaseName + "-" + latestVersion if runtime.GOOS == "windows" { packageName = packageName + ".zip" } else { packageName = packageName + ".gz" } packageURL := baseURL + packageName log.Infoln("updater: updating using url: %s", packageURL) workDir := filepath.Dir(currentExePath) backupDir := filepath.Join(workDir, "meta-backup") updateDir := filepath.Join(workDir, "meta-update") packagePath := filepath.Join(updateDir, packageName) //log.Infoln(packagePath) updateExeName := mihomoBaseName if runtime.GOOS == "windows" { updateExeName = updateExeName + ".exe" } log.Infoln("updateExeName: %s", updateExeName) updateExePath := filepath.Join(updateDir, updateExeName) backupExePath := filepath.Join(backupDir, filepath.Base(currentExePath)) defer u.clean(updateDir) err = u.download(updateDir, packagePath, packageURL) if err != nil { return fmt.Errorf("downloading: %w", err) } err = u.unpack(updateDir, packagePath) if err != nil { return fmt.Errorf("unpacking: %w", err) } err = u.backup(currentExePath, backupExePath, backupDir) if err != nil { return fmt.Errorf("backuping: %w", err) } err = u.replace(updateExePath, currentExePath) if err != nil { return fmt.Errorf("replacing: %w", err) } return nil } func (u *CoreUpdater) getLatestVersion(versionURL string) (version string, err error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() resp, err := mihomoHttp.HttpRequest(ctx, versionURL, http.MethodGet, nil, nil) if err != nil { return "", err } defer func() { closeErr := resp.Body.Close() if closeErr != nil && err == nil { err = closeErr } }() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } content := strings.TrimRight(string(body), "\n") return content, nil } // download package file and save it to disk func (u *CoreUpdater) download(updateDir, packagePath, packageURL string) (err error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*90) defer cancel() resp, err := mihomoHttp.HttpRequest(ctx, packageURL, http.MethodGet, nil, nil) if err != nil { return fmt.Errorf("http request failed: %w", err) } defer func() { closeErr := resp.Body.Close() if closeErr != nil && err == nil { err = closeErr } }() log.Debugln("updater: reading http body") // This use of ReadAll is now safe, because we limited body's Reader. body, err := io.ReadAll(io.LimitReader(resp.Body, MaxPackageFileSize)) if err != nil { return fmt.Errorf("io.ReadAll() failed: %w", err) } log.Debugln("updateDir %s", updateDir) err = os.Mkdir(updateDir, 0o755) if err != nil { return fmt.Errorf("mkdir error: %w", err) } log.Debugln("updater: saving package to file %s", packagePath) err = os.WriteFile(packagePath, body, 0o644) if err != nil { return fmt.Errorf("os.WriteFile() failed: %w", err) } return nil } // unpack extracts the files from the downloaded archive. func (u *CoreUpdater) unpack(updateDir, packagePath string) error { log.Infoln("updater: unpacking package") if strings.HasSuffix(packagePath, ".zip") { _, err := u.zipFileUnpack(packagePath, updateDir) if err != nil { return fmt.Errorf(".zip unpack failed: %w", err) } } else if strings.HasSuffix(packagePath, ".gz") { _, err := u.gzFileUnpack(packagePath, updateDir) if err != nil { return fmt.Errorf(".gz unpack failed: %w", err) } } else { return fmt.Errorf("unknown package extension") } return nil } // backup makes a backup of the current executable file func (u *CoreUpdater) backup(currentExePath, backupExePath, backupDir string) (err error) { log.Infoln("updater: backing up current ExecFile:%s to %s", currentExePath, backupExePath) _ = os.Mkdir(backupDir, 0o755) err = os.Rename(currentExePath, backupExePath) if err != nil { return err } return nil } // replace moves the current executable with the updated one func (u *CoreUpdater) replace(updateExePath, currentExePath string) error { var err error log.Infoln("replacing: %s to %s", updateExePath, currentExePath) if runtime.GOOS == "windows" { // rename fails with "File in use" error err = u.copyFile(updateExePath, currentExePath) } else { err = os.Rename(updateExePath, currentExePath) } if err != nil { return err } log.Infoln("updater: renamed: %s to %s", updateExePath, currentExePath) return nil } // clean removes the temporary directory itself and all it's contents. func (u *CoreUpdater) clean(updateDir string) { _ = os.RemoveAll(updateDir) } // Unpack a single .gz file to the specified directory // Existing files are overwritten // All files are created inside outDir, subdirectories are not created // Return the output file name func (u *CoreUpdater) gzFileUnpack(gzfile, outDir string) (string, error) { f, err := os.Open(gzfile) if err != nil { return "", fmt.Errorf("os.Open(): %w", err) } defer func() { closeErr := f.Close() if closeErr != nil && err == nil { err = closeErr } }() gzReader, err := gzip.NewReader(f) if err != nil { return "", fmt.Errorf("gzip.NewReader(): %w", err) } defer func() { closeErr := gzReader.Close() if closeErr != nil && err == nil { err = closeErr } }() // Get the original file name from the .gz file header originalName := gzReader.Header.Name if originalName == "" { // Fallback: remove the .gz extension from the input file name if the header doesn't provide the original name originalName = filepath.Base(gzfile) originalName = strings.TrimSuffix(originalName, ".gz") } outputName := filepath.Join(outDir, originalName) // Create the output file wc, err := os.OpenFile( outputName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755, ) if err != nil { return "", fmt.Errorf("os.OpenFile(%s): %w", outputName, err) } defer func() { closeErr := wc.Close() if closeErr != nil && err == nil { err = closeErr } }() // Copy the contents of the gzReader to the output file _, err = io.Copy(wc, gzReader) if err != nil { return "", fmt.Errorf("io.Copy(): %w", err) } return outputName, nil } // Unpack a single file from .zip file to the specified directory // Existing files are overwritten // All files are created inside 'outDir', subdirectories are not created // Return the output file name func (u *CoreUpdater) zipFileUnpack(zipfile, outDir string) (string, error) { zrc, err := zip.OpenReader(zipfile) if err != nil { return "", fmt.Errorf("zip.OpenReader(): %w", err) } defer func() { closeErr := zrc.Close() if closeErr != nil && err == nil { err = closeErr } }() if len(zrc.File) == 0 { return "", fmt.Errorf("no files in the zip archive") } // Assuming the first file in the zip archive is the target file zf := zrc.File[0] var rc io.ReadCloser rc, err = zf.Open() if err != nil { return "", fmt.Errorf("zip file Open(): %w", err) } defer func() { closeErr := rc.Close() if closeErr != nil && err == nil { err = closeErr } }() fi := zf.FileInfo() name := fi.Name() outputName := filepath.Join(outDir, name) if fi.IsDir() { return "", fmt.Errorf("the target file is a directory") } var wc io.WriteCloser wc, err = os.OpenFile(outputName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fi.Mode()) if err != nil { return "", fmt.Errorf("os.OpenFile(): %w", err) } defer func() { closeErr := wc.Close() if closeErr != nil && err == nil { err = closeErr } }() _, err = io.Copy(wc, rc) if err != nil { return "", fmt.Errorf("io.Copy(): %w", err) } return outputName, nil } // Copy file on disk func (u *CoreUpdater) copyFile(src, dst string) error { d, e := os.ReadFile(src) if e != nil { return e } e = os.WriteFile(dst, d, 0o644) if e != nil { return e } return nil }