Files
3x-ui-agent/internal/selfupdate/selfupdate.go
T

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
}