feat(update): 第20条 主控面板一键自更新(Go 原生换名 + systemd 重启)

- PanelUpdateInfo 增 displayCurrentVersion/displayLatestVersion/
  displayUpdateAvailable:V 系按个位进位段比较,大于当前才算新版;
  原 currentVersion/latestVersion/updateAvailable 语义不动(节点页
  与传统节点继续用)
- 稳定渠道自更新改 Go 原生路径:查最新 tag→按架构下载发行资产→
  sha256 旁车校验→原子换名(旧二进制留 .bak)→写既有状态文件
  (与 update.sh 同形状,前端轮询协议不变)→脱离进程 systemctl
  restart 本单元(单元名 XUI_SERVICE_NAME 覆盖 / /proc/self/cgroup
  解析 / 回退 x-ui);dev 渠道保留 update.sh 原路径
- 仅 systemd 环境启用(/run/systemd/system 存在或
  XUI_SELFUPDATE_SYSTEMD=1 沙箱钩子),否则报错提示手动更新
- 单测:显示版本比较、cgroup 单元名解析、状态文件往返、systemd
  门拦截
This commit is contained in:
Muse
2026-10-22 13:16:35 +00:00
parent 2365eb138b
commit 2596880327
2 changed files with 253 additions and 2 deletions
+188 -2
View File
@@ -19,6 +19,7 @@ import (
"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"
)
@@ -37,6 +38,13 @@ type PanelUpdateInfo struct {
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 (
@@ -137,12 +145,32 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
return nil, err
}
current := config.GetBaseVersion()
return &PanelUpdateInfo{
info := &PanelUpdateInfo{
Channel: "stable",
CurrentVersion: current,
LatestVersion: latest,
UpdateAvailable: isNewerVersion(latest, current),
}, nil
}
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) {
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, config.GetMasterDisplayVersion())
return disp, ok && cmp > 0
}
// devChannelActive reports whether self-update should track the rolling dev
@@ -213,6 +241,16 @@ func (s *PanelService) GetUpdateStatus() *PanelUpdateStatus {
}
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")
@@ -505,6 +543,154 @@ func commitsEqual(a, b string) bool {
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()
}
tag, err := fetchLatestPanelVersion()
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 + "/"
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
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 == "" {
@@ -0,0 +1,65 @@
package panel
import (
"strings"
"testing"
)
// Item 20: the stable channel compares V-series display versions with
// the segment rule; the native self-update is systemd-gated and records
// its outcome in the shared update status file.
func TestDisplayUpdateFields(t *testing.T) {
// The test binary runs the default display version V1.0.0.
if disp, avail := displayUpdateFields("V1.0.1"); disp != "V1.0.1" || !avail {
t.Fatalf("V1.0.1 vs V1.0.0 = %q, %v; want V1.0.1, true", disp, avail)
}
if disp, avail := displayUpdateFields("V1.0.0"); disp != "V1.0.0" || avail {
t.Fatalf("V1.0.0 vs V1.0.0 = %q, %v; want V1.0.0, false", disp, avail)
}
if disp, avail := displayUpdateFields("3.7.0-agent.1"); disp != "" || avail {
t.Fatalf("legacy tag = %q, %v; want empty, false", disp, avail)
}
if _, avail := displayUpdateFields("V1.0.9"); !avail {
t.Fatal("V1.0.9 must be newer than V1.0.0")
}
}
func TestServiceUnitFromCgroup(t *testing.T) {
content := "0::/system.slice/x-ui.service\n"
if got := serviceUnitFromCgroup(content); got != "x-ui" {
t.Fatalf("unit = %q, want x-ui", got)
}
if got := serviceUnitFromCgroup("0::/user.slice/session-3.scope\n"); got != "" {
t.Fatalf("unit = %q, want empty", got)
}
if got := serviceUnitFromCgroup(""); got != "" {
t.Fatalf("unit = %q, want empty", got)
}
}
func TestNativeUpdateStatusRoundtrip(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
writeUpdateStatus(424242, updateStateSuccess, 0)
st := (&PanelService{}).GetUpdateStatus()
if st.RunID != "424242" || st.State != updateStateSuccess || st.ExitCode != 0 {
t.Fatalf("status roundtrip = %+v", st)
}
writeUpdateStatus(424243, updateStateFailed, 1)
st = (&PanelService{}).GetUpdateStatus()
if st.RunID != "424243" || st.State != updateStateFailed || st.ExitCode != 1 {
t.Fatalf("status roundtrip = %+v", st)
}
}
func TestStartUpdateStableRequiresSystemd(t *testing.T) {
// Force the gate closed regardless of the host running the test.
orig := nativeSystemdAvailable
nativeSystemdAvailable = func() bool { return false }
t.Cleanup(func() { nativeSystemdAvailable = orig })
_, err := (&PanelService{}).startNativeUpdate()
if err == nil || !strings.Contains(err.Error(), "systemd") {
t.Fatalf("want systemd gate error, got %v", err)
}
}