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/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"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/global"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,13 @@ type PanelUpdateInfo struct {
|
|||||||
CurrentCommit string `json:"currentCommit,omitempty"`
|
CurrentCommit string `json:"currentCommit,omitempty"`
|
||||||
LatestCommit string `json:"latestCommit,omitempty"`
|
LatestCommit string `json:"latestCommit,omitempty"`
|
||||||
UpdateAvailable bool `json:"updateAvailable"`
|
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 (
|
const (
|
||||||
@@ -137,12 +145,32 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
current := config.GetBaseVersion()
|
current := config.GetBaseVersion()
|
||||||
return &PanelUpdateInfo{
|
info := &PanelUpdateInfo{
|
||||||
Channel: "stable",
|
Channel: "stable",
|
||||||
CurrentVersion: current,
|
CurrentVersion: current,
|
||||||
LatestVersion: latest,
|
LatestVersion: latest,
|
||||||
UpdateAvailable: isNewerVersion(latest, current),
|
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
|
// 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) {
|
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()
|
runID := time.Now().UnixNano()
|
||||||
if !acquireUpdateSlot(runID) {
|
if !acquireUpdateSlot(runID) {
|
||||||
return 0, fmt.Errorf("a panel update is already in progress")
|
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)
|
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) {
|
func resolveUpdateFolders() (string, string) {
|
||||||
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
||||||
if mainFolder == "" {
|
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