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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user