225 lines
7.2 KiB
Go
225 lines
7.2 KiB
Go
// Package selfupdate carries the shared mechanics of the fork's one-click
|
|
// online update (item 20): release coordinates, sha256-verified download
|
|
// and atomic executable replacement with a .bak rollback copy. It is used
|
|
// by the agent (self-update on master intent) and by the master panel
|
|
// (native self-update on systemd hosts). No new inbound ports anywhere:
|
|
// everything rides the existing HTTPS endpoints.
|
|
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// DefaultBaseURL is the release download base of the fork's own Gitea.
|
|
// A release tag directory hangs directly below it:
|
|
// <base><tag>/x-ui-linux-<arch> plus a "<asset>.sha256" sidecar.
|
|
const DefaultBaseURL = "https://git.881218.xyz/rogue/3x-ui-agent/releases/download/"
|
|
|
|
// LatestReleaseAPI is the Gitea API endpoint for the newest release of the
|
|
// fork's repository (the same source the panel update check reads).
|
|
const LatestReleaseAPI = "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/latest"
|
|
|
|
// BaseURLEnv overrides the release base (mirrors, tests). Read at call
|
|
// time so a process never caches a stale override.
|
|
const BaseURLEnv = "XUI_RELEASE_BASE_URL"
|
|
|
|
// BaseURL returns the effective release download base, always with a
|
|
// trailing slash.
|
|
func BaseURL() string {
|
|
if v := strings.TrimSpace(os.Getenv(BaseURLEnv)); v != "" {
|
|
return strings.TrimSuffix(v, "/") + "/"
|
|
}
|
|
return DefaultBaseURL
|
|
}
|
|
|
|
// AssetName maps a GOARCH to the release asset name published for it.
|
|
func AssetName(goarch string) (string, error) {
|
|
switch goarch {
|
|
case "amd64":
|
|
return "x-ui-linux-amd64", nil
|
|
case "arm64":
|
|
return "x-ui-linux-arm64", nil
|
|
default:
|
|
return "", fmt.Errorf("no release asset for architecture %q", goarch)
|
|
}
|
|
}
|
|
|
|
// LatestReleaseURLEnv overrides the release-lookup endpoint as a whole
|
|
// (mirrors, tests), analogous to BaseURLEnv.
|
|
const LatestReleaseURLEnv = "XUI_RELEASE_LATEST_URL"
|
|
|
|
// LatestReleaseURL returns the effective release-lookup endpoint.
|
|
func LatestReleaseURL() string {
|
|
if v := strings.TrimSpace(os.Getenv(LatestReleaseURLEnv)); v != "" {
|
|
return v
|
|
}
|
|
return LatestReleaseAPI
|
|
}
|
|
|
|
// FetchLatestReleaseTag returns the tag_name of the newest release.
|
|
func FetchLatestReleaseTag(ctx context.Context, client *http.Client) (string, error) {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, LatestReleaseURL(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("release lookup: HTTP %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
TagName string `json:"tag_name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(body.TagName) == "" {
|
|
return "", fmt.Errorf("release lookup: empty tag_name")
|
|
}
|
|
return strings.TrimSpace(body.TagName), nil
|
|
}
|
|
|
|
// ParseSHA256Sidecar extracts the digest from a sha256sum-style sidecar
|
|
// file ("<hex> <name>" or a bare "<hex>", possibly with trailing lines).
|
|
func ParseSHA256Sidecar(data []byte) (string, bool) {
|
|
fields := strings.Fields(string(data))
|
|
if len(fields) == 0 {
|
|
return "", false
|
|
}
|
|
sum := strings.ToLower(fields[0])
|
|
if len(sum) != sha256.Size*2 {
|
|
return "", false
|
|
}
|
|
if _, err := hex.DecodeString(sum); err != nil {
|
|
return "", false
|
|
}
|
|
return sum, true
|
|
}
|
|
|
|
// maxAssetBytes caps a single asset download (release binaries are ~55MB;
|
|
// the cap only guards against a hostile or broken source looping forever).
|
|
const maxAssetBytes = 1 << 30
|
|
|
|
// DownloadVerified streams fileURL to destPath while hashing it, then
|
|
// compares the digest against the sha256 sidecar at sumURL. On any failure
|
|
// the partial destination is removed and the error explains which step
|
|
// broke, so the caller can keep running the old binary untouched.
|
|
func DownloadVerified(ctx context.Context, client *http.Client, fileURL, sumURL, destPath string) error {
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
sumReq, err := http.NewRequestWithContext(ctx, http.MethodGet, sumURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sumResp, err := client.Do(sumReq)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch checksum: %w", err)
|
|
}
|
|
sumBody, err := io.ReadAll(io.LimitReader(sumResp.Body, 4096))
|
|
sumResp.Body.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("read checksum: %w", err)
|
|
}
|
|
if sumResp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("fetch checksum: HTTP %d", sumResp.StatusCode)
|
|
}
|
|
want, ok := ParseSHA256Sidecar(sumBody)
|
|
if !ok {
|
|
return fmt.Errorf("checksum file has no valid sha256 digest")
|
|
}
|
|
|
|
fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileResp, err := client.Do(fileReq)
|
|
if err != nil {
|
|
return fmt.Errorf("download asset: %w", err)
|
|
}
|
|
defer fileResp.Body.Close()
|
|
if fileResp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download asset: HTTP %d", fileResp.StatusCode)
|
|
}
|
|
out, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
|
|
if err != nil {
|
|
return fmt.Errorf("create temp binary: %w", err)
|
|
}
|
|
hasher := sha256.New()
|
|
_, copyErr := io.Copy(io.MultiWriter(out, hasher), io.LimitReader(fileResp.Body, maxAssetBytes+1))
|
|
closeErr := out.Close()
|
|
if copyErr != nil {
|
|
_ = os.Remove(destPath)
|
|
return fmt.Errorf("download asset: %w", copyErr)
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(destPath)
|
|
return fmt.Errorf("write temp binary: %w", closeErr)
|
|
}
|
|
if got := hex.EncodeToString(hasher.Sum(nil)); got != want {
|
|
_ = os.Remove(destPath)
|
|
return fmt.Errorf("sha256 mismatch: got %s, want %s", got, want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SwapExecutable atomically replaces the executable at exePath with the
|
|
// file at newPath, keeping the previous binary at exePath+".bak" for
|
|
// rollback. Both paths must live on the same filesystem (the caller
|
|
// downloads into the executable's directory).
|
|
func SwapExecutable(exePath, newPath string) (backupPath string, err error) {
|
|
backupPath = exePath + ".bak"
|
|
if err := os.Chmod(newPath, 0o755); err != nil {
|
|
return "", fmt.Errorf("chmod new binary: %w", err)
|
|
}
|
|
_ = os.Remove(backupPath)
|
|
if err := os.Rename(exePath, backupPath); err != nil {
|
|
return "", fmt.Errorf("backup current binary: %w", err)
|
|
}
|
|
if err := os.Rename(newPath, exePath); err != nil {
|
|
// Best effort: put the old binary back so the service keeps a
|
|
// runnable executable at the expected path.
|
|
_ = os.Rename(backupPath, exePath)
|
|
return "", fmt.Errorf("install new binary: %w", err)
|
|
}
|
|
return backupPath, nil
|
|
}
|
|
|
|
// RollbackExecutable restores the .bak copy over exePath (used when the
|
|
// post-swap restart fails before the new binary ever runs).
|
|
func RollbackExecutable(exePath string) error {
|
|
backupPath := exePath + ".bak"
|
|
if _, err := os.Stat(backupPath); err != nil {
|
|
return fmt.Errorf("no backup at %s: %w", backupPath, err)
|
|
}
|
|
return os.Rename(backupPath, exePath)
|
|
}
|
|
|
|
// ExecutablePath resolves the running binary's real path.
|
|
func ExecutablePath() (string, error) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
|
return resolved, nil
|
|
}
|
|
return exe, nil
|
|
}
|