Files
3x-ui-agent/internal/agent/update.go
T
Muse 2365eb138b feat(update): 第20条 agent 一键在线更新(意向→轮询→校验换名→自重启)
- 新增 internal/selfupdate:发行坐标(基址常量+env 覆盖)、Gitea
  latest tag 查询、sha256 旁车校验下载、原子换名(旧二进制留 .bak、
  失败可回滚),含单测
- 主控:UpdatePanels 对 agent 节点改为记录更新意向(最新 tag 的
  V 系→A 系目标;已达标报错不记意向);意向经现有 GET config 轮询
  捎带下发;心跳收 updateStatus 回报(failed/current 清意向留状态、
  过期 requested_at 忽略),遥测版本达标自动确认 success
- agent:识别意向→比自身 A 版本(已达标报 current 不下载)→按架构
  下载资产+sha256 校验→换名留 .bak→刷一次心跳→stopXray→
  syscall.Exec 自重启;exec 失败回滚 .bak 并报 failed;失败 10 分钟
  内不重下同一意向;旧版全程保持运行
- NodeView 增 agentUpdateState/Error/Target 供节点页可观测
- 单测:selfupdate 换名/校验、agent 换名重启/坏校验保旧版/已达标
  跳过/exec 失败回滚、主控意向记录/已达标跳过、config 捎带意向、
  心跳 failed 清意向、心跳新版本确认 success
2026-10-22 13:14:24 +00:00

189 lines
6.2 KiB
Go

package agent
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"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"
)
// One-click agent update (item 20). The master records an intent on the
// agent row; the intent rides the existing config poll (no new channel).
// The agent downloads the release binary for its architecture from the
// master's release base, verifies it against the published .sha256
// sidecar, swaps it into place keeping the old binary as .bak, and
// re-execs itself. Any failure before the swap leaves the old binary
// running untouched; the outcome is reported on heartbeats, and the new
// A-series version appearing at the master is the success signal.
// updateIntent is the master's update request as delivered on the config
// response.
type updateIntent struct {
RequestedAt int64 `json:"requested_at"`
Target string `json:"target"` // A-form display target, e.g. "A1.0.1"
Tag string `json:"tag"` // release tag, e.g. "V1.0.1"
BaseURL string `json:"base_url"` // release download base (trailing slash)
}
// agentUpdateStatus mirrors the master's heartbeat updateStatus object.
type agentUpdateStatus struct {
RequestedAt int64 `json:"requestedAt"`
State string `json:"state"` // downloading | swapped | failed | current
Error string `json:"error,omitempty"`
}
// updateRetryBackoff suppresses immediate re-downloads after a failure for
// the same intent (the master clears failed intents on report, so this
// only guards a master that keeps re-offering the same requested_at).
const updateRetryBackoff = 10 * time.Minute
// execSelf replaces the current process image with the binary at path.
// A package variable so tests can stub the restart.
var execSelf = func(path string) error {
return syscall.Exec(path, append([]string{path}, os.Args[1:]...), os.Environ())
}
func (a *Agent) setUpdateReport(st agentUpdateStatus) {
a.updateMu.Lock()
cp := st
a.updateReport = &cp
a.updateMu.Unlock()
}
func (a *Agent) currentUpdateReport() *agentUpdateStatus {
a.updateMu.Lock()
defer a.updateMu.Unlock()
if a.updateReport == nil {
return nil
}
cp := *a.updateReport
return &cp
}
// maybeHandleUpdate is called on every config poll with the intent the
// master attached (nil when none). Terminal reports are dropped once the
// master withdraws the intent, so heartbeats stop repeating them.
func (a *Agent) maybeHandleUpdate(intent *updateIntent) {
if intent == nil {
a.updateMu.Lock()
if a.updateReport != nil &&
(a.updateReport.State == "failed" || a.updateReport.State == "current") {
a.updateReport = nil
}
a.updateMu.Unlock()
return
}
a.updateMu.Lock()
if a.updateRunning || intent.RequestedAt == a.updateHandled ||
(intent.RequestedAt == a.updateFailedReq && time.Since(a.updateFailedAt) < updateRetryBackoff) {
a.updateMu.Unlock()
return
}
a.updateRunning = true
a.updateMu.Unlock()
go a.runUpdate(*intent)
}
// runUpdate executes one intent end to end. It runs synchronously in its
// own goroutine (spawned by maybeHandleUpdate) — tests call it directly.
func (a *Agent) runUpdate(intent updateIntent) {
defer func() {
a.updateMu.Lock()
a.updateRunning = false
a.updateMu.Unlock()
}()
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "downloading"})
fail := func(err error) {
a.updateMu.Lock()
a.updateHandled = intent.RequestedAt
a.updateFailedReq = intent.RequestedAt
a.updateFailedAt = time.Now()
a.updateMu.Unlock()
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "failed", Error: err.Error()})
logger.Warningf("agent update to %s failed: %v (old version keeps running)", intent.Target, err)
}
// Already at (or beyond) the target — e.g. the intent is re-delivered
// after the swap-restart before the master closes it out. Report
// "current" so the master clears the intent without a download.
if cmp, ok := config.CompareDisplayVersion(config.GetAgentDisplayVersion(), intent.Target); ok && cmp >= 0 {
a.updateMu.Lock()
a.updateHandled = intent.RequestedAt
a.updateMu.Unlock()
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "current"})
return
}
exePath, err := a.selfExePath()
if err != nil {
fail(fmt.Errorf("locate own binary: %w", err))
return
}
asset, err := selfupdate.AssetName(runtime.GOARCH)
if err != nil {
fail(err)
return
}
base := intent.BaseURL
if base == "" {
base = selfupdate.BaseURL()
}
base = strings.TrimSuffix(base, "/") + "/" + intent.Tag + "/"
tmp := filepath.Join(filepath.Dir(exePath), fmt.Sprintf(".x-ui-update-%d", intent.RequestedAt))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
var transport http.RoundTripper
if a.httpClient != nil {
transport = a.httpClient.Transport
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Minute}
if err := selfupdate.DownloadVerified(ctx, client, base+asset, base+asset+".sha256", tmp); err != nil {
_ = os.Remove(tmp)
fail(err)
return
}
if _, err := selfupdate.SwapExecutable(exePath, tmp); err != nil {
_ = os.Remove(tmp)
fail(err)
return
}
a.updateMu.Lock()
a.updateHandled = intent.RequestedAt
a.updateMu.Unlock()
a.setUpdateReport(agentUpdateStatus{RequestedAt: intent.RequestedAt, State: "swapped"})
logger.Infof("agent update to %s downloaded, verified and swapped in; restarting", intent.Target)
// Best effort: flush the "swapped" report before the image is replaced
// (success is ultimately proven by the new version's telemetry).
_ = a.sendHeartbeat()
a.stopXray()
if err := execSelf(exePath); err != nil {
// The exec failed before replacing the image: roll the old binary
// back into place and keep running it.
if rbErr := selfupdate.RollbackExecutable(exePath); rbErr != nil {
logger.Warningf("agent update rollback failed: %v", rbErr)
}
fail(fmt.Errorf("restart into new binary: %w", err))
}
}
// selfExePath resolves the agent's own binary path (os.Executable unless a
// test overrode it).
func (a *Agent) selfExePath() (string, error) {
if a.exePathOverride != "" {
return a.exePathOverride, nil
}
return selfupdate.ExecutablePath()
}