displayUpdateFields returned avail=false whenever the running build's display version could not be compared (the VV2.1.0 double-letter builds), so the panel swallowed a real update prompt and the native updater refused to run. displayUpdateFieldsFor now takes the current display/raw versions as inputs and, when the display comparison cannot judge, compares the latest tag against the raw base version with the same tolerant display parser — unparseable raw base yields false. (Not isNewerVersion: on this fork's uppercase-V tags it degrades to string inequality, which would phantom-report an update on form difference alone.) Parseable display versions keep the segment rule untouched. Regression tests cover the VV scenario in both raw forms.
785 lines
26 KiB
Go
785 lines
26 KiB
Go
package panel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
// PanelService provides business logic for panel management operations.
|
|
// It handles panel restart, updates, and system-level panel controls.
|
|
type PanelService struct{}
|
|
|
|
// PanelUpdateInfo contains the current and latest available panel versions.
|
|
// On the dev channel the version fields carry a "dev+<sha>" label and the commit
|
|
// fields hold the short SHAs that drive the update-available decision.
|
|
type PanelUpdateInfo struct {
|
|
Channel string `json:"channel"`
|
|
CurrentVersion string `json:"currentVersion"`
|
|
LatestVersion string `json:"latestVersion"`
|
|
CurrentCommit string `json:"currentCommit,omitempty"`
|
|
LatestCommit string `json:"latestCommit,omitempty"`
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
// Display versions (item 18/20): the V-series numbers the UI shows for
|
|
// this fork. DisplayUpdateAvailable compares them with the
|
|
// single-digit-carry segment rule — the raw tag stream above keeps its
|
|
// historical semantics for everything else (nodes page, remotes).
|
|
DisplayCurrentVersion string `json:"displayCurrentVersion,omitempty"`
|
|
DisplayLatestVersion string `json:"displayLatestVersion,omitempty"`
|
|
DisplayUpdateAvailable bool `json:"displayUpdateAvailable"`
|
|
}
|
|
|
|
const (
|
|
panelUpdaterURL = "https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/update.sh"
|
|
maxPanelUpdaterBytes = 2 << 20
|
|
// devReleaseTag is the fixed-tag rolling pre-release the CI force-moves to the
|
|
// newest main commit; the dev update channel installs from it.
|
|
devReleaseTag = "dev-latest"
|
|
|
|
updateStatePending = "pending"
|
|
updateStateSuccess = "success"
|
|
updateStateFailed = "failed"
|
|
)
|
|
|
|
// PanelUpdateStatus reports the outcome of the most recently launched panel
|
|
// self-update. RunID lets the caller confirm this status belongs to the
|
|
// update it started rather than a stale result left over from an earlier
|
|
// run; State is one of "pending", "success", or "failed". RunID is a decimal
|
|
// string, not a JSON number: it's a formatted UnixNano timestamp, and
|
|
// JavaScript's number type can't represent that precisely (it exceeds
|
|
// Number.MAX_SAFE_INTEGER), which would let two different runs round to the
|
|
// same value on the wire and defeat the whole point of this field.
|
|
type PanelUpdateStatus struct {
|
|
RunID string `json:"runId" example:"1735689600123456789"`
|
|
State string `json:"state" example:"success"`
|
|
ExitCode int `json:"exitCode" example:"0"`
|
|
FinishedAt int64 `json:"finishedAt" example:"1735689612"`
|
|
}
|
|
|
|
var releaseCommitRegex = regexp.MustCompile(`(?i)commit=([0-9a-f]{7,40})`)
|
|
|
|
// updateMu guards updateRunning/updateStarted/updateRunID/updatePID, which
|
|
// stop a second self-update from launching while one is still in flight (two
|
|
// concurrent update.sh runs would race each other extracting the release
|
|
// tarball and swapping the service unit). A slot is released as soon as the
|
|
// in-flight run's own status file reports success or failure -- checked
|
|
// against updateRunID so a stale file from an even earlier run can't be
|
|
// mistaken for this one finishing -- so a fast failure doesn't lock out a
|
|
// retry.
|
|
//
|
|
// For a run that never reaches a terminal state at all, staleness is judged
|
|
// primarily by whether the process we actually launched is still alive
|
|
// (updatePID, via processAlive), not by wall-clock time alone: update.sh
|
|
// runs install_base() (a package-manager update+install) before anything
|
|
// else, plus several downloads, which can legitimately run past a short
|
|
// fixed timeout on a slow or throttled host without anything being wrong.
|
|
// updateStaleAfter/updatePID together are only a fallback for the systemd-run
|
|
// launch path, where the process we can observe (systemd-run itself) has
|
|
// already exited by the time startUpdate returns and the actual update.sh
|
|
// unit's PID is never recorded -- for that path this is still a pure
|
|
// wall-clock heuristic. updateHardCeiling is an absolute backstop so a
|
|
// genuinely wedged run (alive but hung forever) can never lock out retries
|
|
// permanently, even on the PID-tracked path.
|
|
var (
|
|
updateMu sync.Mutex
|
|
updateRunning bool
|
|
updateStarted time.Time
|
|
updateRunID int64
|
|
updatePID int
|
|
)
|
|
|
|
const (
|
|
updateStaleAfter = 20 * time.Minute
|
|
updateHardCeiling = 2 * time.Hour
|
|
)
|
|
|
|
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
|
go func() {
|
|
time.Sleep(delay)
|
|
if global.TriggerRestart() {
|
|
return
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
|
|
return
|
|
}
|
|
p, err := os.FindProcess(syscall.Getpid())
|
|
if err != nil {
|
|
logger.Error("panel restart: FindProcess failed:", err)
|
|
return
|
|
}
|
|
if err := p.Signal(syscall.SIGHUP); err != nil {
|
|
logger.Error("failed to send SIGHUP signal:", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// GetUpdateInfo checks this fork's Gitea repository for the latest release. When the dev channel
|
|
// is enabled on a dev build it compares commits against the rolling dev release;
|
|
// otherwise it compares versions against the latest stable tag.
|
|
func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
|
if devChannelActive() {
|
|
return getDevUpdateInfo()
|
|
}
|
|
latest, err := fetchLatestPanelVersion()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
current := config.GetBaseVersion()
|
|
info := &PanelUpdateInfo{
|
|
Channel: "stable",
|
|
CurrentVersion: current,
|
|
LatestVersion: latest,
|
|
UpdateAvailable: isNewerVersion(latest, current),
|
|
}
|
|
info.DisplayCurrentVersion = config.GetMasterDisplayVersion()
|
|
if dispLatest, avail := displayUpdateFields(latest); dispLatest != "" {
|
|
info.DisplayLatestVersion = dispLatest
|
|
info.DisplayUpdateAvailable = avail
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// displayUpdateFields maps a raw latest release tag onto the V-series
|
|
// display version and whether it is strictly newer than the running
|
|
// display version. An unparseable tag (not a V-series release) yields ""
|
|
// so the UI falls back to the raw fields.
|
|
func displayUpdateFields(latestTag string) (string, bool) {
|
|
return displayUpdateFieldsFor(latestTag, config.GetMasterDisplayVersion(), config.GetBaseVersion())
|
|
}
|
|
|
|
// displayUpdateFieldsFor is displayUpdateFields with the running
|
|
// build's versions as inputs. When the DISPLAY comparison cannot
|
|
// judge — the running build's display version is itself unparseable,
|
|
// the "VV2.1.0" double-letter builds — the verdict falls back to the
|
|
// raw version comparison instead of reporting "no update": the
|
|
// display path used to swallow a real update entirely (the panel
|
|
// showed no prompt while /releases/latest happily served the newer
|
|
// tag, and the native updater refused to run). A parseable display
|
|
// version keeps the segment rule exactly as before.
|
|
func displayUpdateFieldsFor(latestTag, currentDisplay, currentRaw string) (string, bool) {
|
|
parts, ok := config.ParseDisplayVersion(latestTag)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
disp := fmt.Sprintf("V%d.%d.%d", parts[0], parts[1], parts[2])
|
|
cmp, ok := config.CompareDisplayVersion(disp, currentDisplay)
|
|
if !ok {
|
|
// Raw fallback: judge the latest tag against the running
|
|
// build's raw base version, parsed with the same tolerant
|
|
// display parser (bare and V/A forms both parse). An
|
|
// unparseable raw base yields no verdict → false. This is
|
|
// deliberately NOT isNewerVersion: on this fork's uppercase-V
|
|
// tags that helper degrades to string inequality (its
|
|
// normalizer strips only a lowercase "v"), which would report
|
|
// a phantom update whenever latest merely differs in form.
|
|
rawCmp, rawOk := config.CompareDisplayVersion(disp, currentRaw)
|
|
return disp, rawOk && rawCmp > 0
|
|
}
|
|
return disp, cmp > 0
|
|
}
|
|
|
|
// devChannelActive reports whether self-update should track the rolling dev
|
|
// release. It is driven solely by the opt-in setting so the panel can
|
|
// cross-grade a stable build onto the dev channel once the user enables it;
|
|
// nothing updates without an explicit user action, so an unattended stable
|
|
// binary with the toggle off stays on the stable channel.
|
|
func devChannelActive() bool {
|
|
enabled, err := (&service.SettingService{}).GetDevChannelEnable()
|
|
return err == nil && enabled
|
|
}
|
|
|
|
// getDevUpdateInfo compares the running commit against the commit recorded in the
|
|
// rolling dev release.
|
|
func getDevUpdateInfo() (*PanelUpdateInfo, error) {
|
|
release, err := fetchPanelRelease(devReleaseTag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
latestCommit := extractReleaseCommit(release)
|
|
if latestCommit == "" {
|
|
return nil, fmt.Errorf("dev release commit is unknown")
|
|
}
|
|
currentCommit := config.GetBuildCommit()
|
|
return &PanelUpdateInfo{
|
|
Channel: "dev",
|
|
CurrentVersion: config.GetPanelVersion(),
|
|
CurrentCommit: shortCommit(currentCommit),
|
|
LatestCommit: shortCommit(latestCommit),
|
|
LatestVersion: "dev+" + shortCommit(latestCommit),
|
|
UpdateAvailable: !commitsEqual(currentCommit, latestCommit),
|
|
}, nil
|
|
}
|
|
|
|
// StartUpdate starts the official updater using this panel's own channel
|
|
// setting. Returns the run ID to pass to GetUpdateStatus so the caller can
|
|
// tell this run's result apart from a stale one.
|
|
func (s *PanelService) StartUpdate() (int64, error) {
|
|
return s.startUpdate(devChannelActive())
|
|
}
|
|
|
|
// StartUpdateChannel runs the updater against an explicitly chosen channel,
|
|
// overriding the local dev-channel setting. Used by the master node updater so
|
|
// a node can be moved to the dev channel from the central panel.
|
|
func (s *PanelService) StartUpdateChannel(dev bool) (int64, error) {
|
|
return s.startUpdate(dev)
|
|
}
|
|
|
|
// GetUpdateStatus reports the outcome of the most recently launched panel
|
|
// self-update, as recorded by update.sh's EXIT trap (see the script for why
|
|
// that covers every exit path, not just the happy one). This is a best-effort
|
|
// side channel: a missing or unreadable status file reads as "pending"
|
|
// rather than an error, since the update itself is what matters, not this
|
|
// status file.
|
|
func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus {
|
|
data, err := os.ReadFile(config.GetUpdateStatusFilePath())
|
|
if err != nil {
|
|
return &PanelUpdateStatus{State: updateStatePending}
|
|
}
|
|
var status PanelUpdateStatus
|
|
if err := json.Unmarshal(data, &status); err != nil {
|
|
return &PanelUpdateStatus{State: updateStatePending}
|
|
}
|
|
if status.State != updateStateSuccess && status.State != updateStateFailed {
|
|
status.State = updateStatePending
|
|
}
|
|
return &status
|
|
}
|
|
|
|
func (s *PanelService) startUpdate(useDev bool) (int64, error) {
|
|
// The dev channel keeps the historical update.sh path. The stable
|
|
// channel uses the Go-native swap (item 20): the fork's own releases
|
|
// carry bare binaries, not the tarball layout update.sh expects.
|
|
if useDev {
|
|
return s.startScriptUpdate(useDev)
|
|
}
|
|
return s.startNativeUpdate()
|
|
}
|
|
|
|
func (s *PanelService) startScriptUpdate(useDev bool) (int64, error) {
|
|
runID := time.Now().UnixNano()
|
|
if !acquireUpdateSlot(runID) {
|
|
return 0, fmt.Errorf("a panel update is already in progress")
|
|
}
|
|
launched := false
|
|
defer func() {
|
|
if !launched {
|
|
releaseUpdateSlot()
|
|
}
|
|
}()
|
|
|
|
if runtime.GOOS != "linux" {
|
|
return 0, fmt.Errorf("panel web update is supported only on Linux installations")
|
|
}
|
|
|
|
bash, err := exec.LookPath("bash")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("bash is required to run the panel updater: %w", err)
|
|
}
|
|
|
|
scriptPath, err := downloadPanelUpdater()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
statusFile := config.GetUpdateStatusFilePath()
|
|
|
|
mainFolder, serviceFolder := resolveUpdateFolders()
|
|
updateTag := ""
|
|
if useDev {
|
|
updateTag = devReleaseTag
|
|
}
|
|
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
|
|
runIDEnv := "XUI_UPDATE_RUN_ID=" + strconv.FormatInt(runID, 10)
|
|
statusFileEnv := "XUI_UPDATE_STATUS_FILE=" + statusFile
|
|
proxyEnv := updateProxyEnvVars()
|
|
|
|
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
|
|
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
|
|
args := []string{
|
|
"--unit", unitName,
|
|
"--setenv", "XUI_MAIN_FOLDER=" + mainFolder,
|
|
"--setenv", "XUI_SERVICE=" + serviceFolder,
|
|
"--setenv", "XUI_UPDATE_TAG=" + updateTag,
|
|
"--setenv", runIDEnv,
|
|
"--setenv", statusFileEnv,
|
|
}
|
|
for _, kv := range proxyEnv {
|
|
args = append(args, "--setenv", kv)
|
|
}
|
|
args = append(args, bash, "-lc", updateScript)
|
|
cmd := exec.CommandContext(context.Background(), systemdRun, args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
output := strings.TrimSpace(string(out))
|
|
if !strings.Contains(output, "System has not been booted with systemd") &&
|
|
!strings.Contains(output, "Failed to connect to bus") {
|
|
_ = os.Remove(scriptPath)
|
|
return 0, fmt.Errorf("failed to start panel update job: %w: %s", err, output)
|
|
}
|
|
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
|
|
} else {
|
|
logger.Infof("started panel update job via systemd-run unit %s", unitName)
|
|
launched = true
|
|
return runID, nil
|
|
}
|
|
}
|
|
|
|
cmd := exec.CommandContext(context.Background(), bash, "-lc", updateScript)
|
|
cmd.Env = append(os.Environ(),
|
|
"XUI_MAIN_FOLDER="+mainFolder,
|
|
"XUI_SERVICE="+serviceFolder,
|
|
"XUI_UPDATE_TAG="+updateTag,
|
|
runIDEnv,
|
|
statusFileEnv,
|
|
)
|
|
setDetachedProcess(cmd)
|
|
if err := cmd.Start(); err != nil {
|
|
_ = os.Remove(scriptPath)
|
|
return 0, fmt.Errorf("failed to start panel update job: %w", err)
|
|
}
|
|
if err := cmd.Process.Release(); err != nil {
|
|
logger.Warning("failed to release panel update process:", err)
|
|
}
|
|
logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
|
|
recordUpdatePID(cmd.Process.Pid)
|
|
launched = true
|
|
return runID, nil
|
|
}
|
|
|
|
// updateProxyEnvVars forwards ambient proxy env vars to systemd-run's child,
|
|
// which (unlike the bash fallback) inherits nothing but --setenv.
|
|
func updateProxyEnvVars() []string {
|
|
var out []string
|
|
for _, key := range []string{"https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", "http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY"} {
|
|
if v := os.Getenv(key); v != "" {
|
|
out = append(out, key+"="+v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// acquireUpdateSlot claims the single in-flight-update slot for runID. It
|
|
// refuses while another run is genuinely still in flight, but grants the
|
|
// slot immediately once that run's own status file reports a terminal
|
|
// result (success or failure) -- a fast failure shouldn't force the next
|
|
// attempt to wait out updateStaleAfter for no reason. Past updateStaleAfter
|
|
// with no terminal status yet, it grants the slot anyway UNLESS the process
|
|
// we recorded (updatePID) is confirmed still alive, so a merely-slow run
|
|
// isn't mistaken for a crashed one; past updateHardCeiling it grants the
|
|
// slot unconditionally regardless of liveness, so a truly wedged run can
|
|
// never lock out retries forever.
|
|
func acquireUpdateSlot(runID int64) bool {
|
|
updateMu.Lock()
|
|
defer updateMu.Unlock()
|
|
if updateRunning && !previousRunIsTerminal() {
|
|
elapsed := time.Since(updateStarted)
|
|
if elapsed < updateHardCeiling {
|
|
stale := elapsed >= updateStaleAfter
|
|
alive := updatePID > 0 && processAlive(updatePID)
|
|
if !stale || alive {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
updateRunning = true
|
|
updateStarted = time.Now()
|
|
updateRunID = runID
|
|
updatePID = 0
|
|
return true
|
|
}
|
|
|
|
// recordUpdatePID notes the PID of the detached update.sh process the
|
|
// current slot is tracking, so a later acquireUpdateSlot call can check
|
|
// whether it is actually still running instead of only how long ago it
|
|
// started. Only reachable for the detached-fallback launch path -- the
|
|
// systemd-run path never learns update.sh's own PID, since the process it
|
|
// directly observes (systemd-run) has already exited by the time it returns.
|
|
func recordUpdatePID(pid int) {
|
|
updateMu.Lock()
|
|
updatePID = pid
|
|
updateMu.Unlock()
|
|
}
|
|
|
|
// previousRunIsTerminal reports whether the run currently recorded in
|
|
// updateRunID has reached success or failure per its status file. Must be
|
|
// called with updateMu held.
|
|
func previousRunIsTerminal() bool {
|
|
status := (&PanelService{}).GetUpdateStatus()
|
|
return status.RunID == strconv.FormatInt(updateRunID, 10) && status.State != updateStatePending
|
|
}
|
|
|
|
func releaseUpdateSlot() {
|
|
updateMu.Lock()
|
|
updateRunning = false
|
|
updateMu.Unlock()
|
|
}
|
|
|
|
func downloadPanelUpdater() (string, error) {
|
|
client := (&service.SettingService{}).NewProxiedHTTPClient(15 * time.Second)
|
|
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, panelUpdaterURL, nil)
|
|
if reqErr != nil {
|
|
return "", fmt.Errorf("download panel updater: %w", reqErr)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("download panel updater: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("download panel updater: unexpected HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
file, err := os.CreateTemp("", "3x-ui-update-*.sh")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := file.Name()
|
|
ok := false
|
|
defer func() {
|
|
_ = file.Close()
|
|
if !ok {
|
|
_ = os.Remove(path)
|
|
}
|
|
}()
|
|
|
|
n, err := io.Copy(file, io.LimitReader(resp.Body, maxPanelUpdaterBytes+1))
|
|
if err != nil {
|
|
return "", fmt.Errorf("write panel updater: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return "", fmt.Errorf("panel updater download is empty")
|
|
}
|
|
if n > maxPanelUpdaterBytes {
|
|
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
|
|
}
|
|
if err := file.Chmod(0o700); err != nil {
|
|
return "", err
|
|
}
|
|
ok = true
|
|
return path, nil
|
|
}
|
|
|
|
func fetchLatestPanelVersion() (string, error) {
|
|
release, err := fetchPanelRelease("")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if release.TagName == "" {
|
|
return "", fmt.Errorf("latest panel release tag is empty")
|
|
}
|
|
return release.TagName, nil
|
|
}
|
|
|
|
// fetchPanelRelease fetches a release from this fork's Gitea repository
|
|
// (its API returns the same tag_name/body/target_commitish shape). An empty
|
|
// tag resolves the latest stable release; a non-empty tag (e.g. dev-latest)
|
|
// resolves that tag.
|
|
func fetchPanelRelease(tag string) (*service.Release, error) {
|
|
url := "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/latest"
|
|
if tag != "" {
|
|
url = "https://git.881218.xyz/api/v1/repos/rogue/3x-ui-agent/releases/tags/" + tag
|
|
}
|
|
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
|
|
req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
|
if reqErr != nil {
|
|
return nil, reqErr
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("release API returned status %d: %s", resp.StatusCode, resp.Status)
|
|
}
|
|
|
|
var release service.Release
|
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
return nil, err
|
|
}
|
|
return &release, nil
|
|
}
|
|
|
|
// extractReleaseCommit reads the build commit recorded in the dev release: first
|
|
// the `commit=<sha>` marker the CI writes into the body, falling back to the
|
|
// tag's target commit.
|
|
func extractReleaseCommit(release *service.Release) string {
|
|
if m := releaseCommitRegex.FindStringSubmatch(release.Body); m != nil {
|
|
return strings.ToLower(m[1])
|
|
}
|
|
if isCommitSHA(release.TargetCommitish) {
|
|
return strings.ToLower(release.TargetCommitish)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isCommitSHA(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) < 7 || len(s) > 40 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func shortCommit(sha string) string {
|
|
sha = strings.TrimSpace(sha)
|
|
if len(sha) > 8 {
|
|
return sha[:8]
|
|
}
|
|
return sha
|
|
}
|
|
|
|
// commitsEqual compares a short (injected) commit against a full release commit
|
|
// by prefix, so an 8-char build stamp matches the 40-char release SHA.
|
|
func commitsEqual(a, b string) bool {
|
|
a = strings.ToLower(strings.TrimSpace(a))
|
|
b = strings.ToLower(strings.TrimSpace(b))
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
if len(a) > len(b) {
|
|
a, b = b, a
|
|
}
|
|
return strings.HasPrefix(b, a)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Native stable-channel self-update (item 20).
|
|
//
|
|
// The fork's own releases ship bare binaries (x-ui-linux-<arch> + .sha256),
|
|
// so the stable channel swaps the binary in Go instead of running
|
|
// update.sh: resolve the newest V-series tag, download this host's asset
|
|
// with sha256 verification, atomically rename it over the running binary
|
|
// (old one kept as .bak), record success in the same status file the
|
|
// update.sh path uses, then restart the systemd unit from a detached
|
|
// process. Only systemd hosts are eligible — anywhere else the operator
|
|
// updates manually, as before.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// nativeSystemdAvailable reports whether this process runs under systemd.
|
|
// XUI_SELFUPDATE_SYSTEMD=1 forces the gate open (sandbox verification with
|
|
// a stub systemctl on PATH).
|
|
var nativeSystemdAvailable = func() bool {
|
|
if os.Getenv("XUI_SELFUPDATE_SYSTEMD") == "1" {
|
|
return true
|
|
}
|
|
_, err := os.Stat("/run/systemd/system")
|
|
return err == nil
|
|
}
|
|
|
|
// scheduleServiceRestart restarts the panel's unit from a detached
|
|
// process, so the restart is not killed together with this process.
|
|
// A variable so tests can stub it.
|
|
var scheduleServiceRestart = func(unit string) error {
|
|
cmd := exec.Command("sh", "-c", "sleep 1; systemctl restart "+shellQuote(unit))
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
return cmd.Process.Release()
|
|
}
|
|
|
|
func (s *PanelService) startNativeUpdate() (int64, error) {
|
|
if runtime.GOOS != "linux" {
|
|
return 0, fmt.Errorf("panel web update is supported only on Linux installations")
|
|
}
|
|
if !nativeSystemdAvailable() {
|
|
return 0, fmt.Errorf("panel online update requires a systemd environment; please update manually")
|
|
}
|
|
runID := time.Now().UnixNano()
|
|
if !acquireUpdateSlot(runID) {
|
|
return 0, fmt.Errorf("a panel update is already in progress")
|
|
}
|
|
go s.runNativeUpdate(runID)
|
|
return runID, nil
|
|
}
|
|
|
|
func (s *PanelService) runNativeUpdate(runID int64) {
|
|
fail := func(format string, args ...any) {
|
|
logger.Warning("panel self-update failed: ", fmt.Sprintf(format, args...))
|
|
writeUpdateStatus(runID, updateStateFailed, 1)
|
|
releaseUpdateSlot()
|
|
}
|
|
|
|
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Minute)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
tag, err := selfupdate.FetchLatestReleaseTag(ctx, client)
|
|
if err != nil {
|
|
fail("fetch latest release: %v", err)
|
|
return
|
|
}
|
|
if _, avail := displayUpdateFields(tag); !avail {
|
|
fail("latest release %s is not newer than current %s", tag, config.GetMasterDisplayVersion())
|
|
return
|
|
}
|
|
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
|
if err != nil {
|
|
fail("%v", err)
|
|
return
|
|
}
|
|
exePath, err := selfupdate.ExecutablePath()
|
|
if err != nil {
|
|
fail("locate own binary: %v", err)
|
|
return
|
|
}
|
|
tmp := filepath.Join(filepath.Dir(exePath), fmt.Sprintf(".x-ui-update-%d", runID))
|
|
base := selfupdate.BaseURL() + tag + "/"
|
|
logger.Infof("panel self-update: downloading %s (%s)", tag, asset)
|
|
if err := selfupdate.DownloadVerified(ctx, client, base+asset, base+asset+".sha256", tmp); err != nil {
|
|
_ = os.Remove(tmp)
|
|
fail("download %s: %v", tag, err)
|
|
return
|
|
}
|
|
bak, err := selfupdate.SwapExecutable(exePath, tmp)
|
|
if err != nil {
|
|
_ = os.Remove(tmp)
|
|
fail("swap binary: %v", err)
|
|
return
|
|
}
|
|
logger.Infof("panel self-update: %s installed (previous binary kept at %s); restarting service", tag, bak)
|
|
writeUpdateStatus(runID, updateStateSuccess, 0)
|
|
releaseUpdateSlot()
|
|
if err := scheduleServiceRestart(detectServiceUnit()); err != nil {
|
|
logger.Warning("panel self-update: schedule restart failed (new binary takes effect at the next restart): ", err)
|
|
}
|
|
}
|
|
|
|
// detectServiceUnit resolves this process's systemd unit: explicit
|
|
// override, else the unit named in /proc/self/cgroup, else "x-ui".
|
|
func detectServiceUnit() string {
|
|
if v := strings.TrimSpace(os.Getenv("XUI_SERVICE_NAME")); v != "" {
|
|
return v
|
|
}
|
|
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
|
if unit := serviceUnitFromCgroup(string(data)); unit != "" {
|
|
return unit
|
|
}
|
|
}
|
|
return "x-ui"
|
|
}
|
|
|
|
// serviceUnitFromCgroup extracts the first *.service component from
|
|
// cgroup content, without the suffix ("system.slice/x-ui.service" ->
|
|
// "x-ui").
|
|
func serviceUnitFromCgroup(content string) string {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
for _, part := range strings.Split(line, "/") {
|
|
if strings.HasSuffix(part, ".service") {
|
|
return strings.TrimSuffix(part, ".service")
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writeUpdateStatus records a native-update outcome in the same status
|
|
// file and JSON shape the update.sh path produces for GetUpdateStatus.
|
|
func writeUpdateStatus(runID int64, state string, exitCode int) {
|
|
status := PanelUpdateStatus{
|
|
RunID: strconv.FormatInt(runID, 10),
|
|
State: state,
|
|
ExitCode: exitCode,
|
|
FinishedAt: time.Now().Unix(),
|
|
}
|
|
data, err := json.Marshal(status)
|
|
if err != nil {
|
|
return
|
|
}
|
|
path := config.GetUpdateStatusFilePath()
|
|
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
|
_ = os.WriteFile(path, data, 0o644)
|
|
}
|
|
|
|
func resolveUpdateFolders() (string, string) {
|
|
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
|
if mainFolder == "" {
|
|
if exePath, err := os.Executable(); err == nil {
|
|
mainFolder = filepath.Dir(exePath)
|
|
}
|
|
}
|
|
if mainFolder == "" {
|
|
mainFolder = "/usr/local/x-ui"
|
|
}
|
|
|
|
serviceFolder := os.Getenv("XUI_SERVICE")
|
|
if serviceFolder == "" {
|
|
serviceFolder = "/etc/systemd/system"
|
|
}
|
|
return mainFolder, serviceFolder
|
|
}
|
|
|
|
func isNewerVersion(latest string, current string) bool {
|
|
cmp, ok := compareVersionStrings(latest, current)
|
|
if !ok {
|
|
return normalizeVersionTag(latest) != normalizeVersionTag(current)
|
|
}
|
|
return cmp > 0
|
|
}
|
|
|
|
func compareVersionStrings(a string, b string) (int, bool) {
|
|
aParts, okA := parseVersionParts(a)
|
|
bParts, okB := parseVersionParts(b)
|
|
if !okA || !okB {
|
|
return 0, false
|
|
}
|
|
for i := range len(aParts) {
|
|
if aParts[i] > bParts[i] {
|
|
return 1, true
|
|
}
|
|
if aParts[i] < bParts[i] {
|
|
return -1, true
|
|
}
|
|
}
|
|
return 0, true
|
|
}
|
|
|
|
func parseVersionParts(version string) ([3]int, bool) {
|
|
var result [3]int
|
|
parts := strings.Split(normalizeVersionTag(version), ".")
|
|
if len(parts) != 3 {
|
|
return result, false
|
|
}
|
|
for i, part := range parts {
|
|
n, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return result, false
|
|
}
|
|
result[i] = n
|
|
}
|
|
return result, true
|
|
}
|
|
|
|
func normalizeVersionTag(version string) string {
|
|
return strings.TrimPrefix(strings.TrimSpace(version), "v")
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
|
}
|