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
This commit is contained in:
+19
-2
@@ -75,6 +75,15 @@ type Agent struct {
|
||||
xrayVer string
|
||||
xrayVerFor string // binary path the cached version was read from
|
||||
xrayVerMod time.Time
|
||||
|
||||
// One-click update bookkeeping (item 20, see update.go).
|
||||
updateMu sync.Mutex
|
||||
updateRunning bool
|
||||
updateHandled int64
|
||||
updateFailedReq int64
|
||||
updateFailedAt time.Time
|
||||
updateReport *agentUpdateStatus
|
||||
exePathOverride string
|
||||
}
|
||||
|
||||
// New creates a new agent instance
|
||||
@@ -272,6 +281,9 @@ func (a *Agent) sendHeartbeat() error {
|
||||
"timestamp": time.Now().Unix(),
|
||||
"telemetry": a.collectTelemetry(),
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep != nil {
|
||||
payload["updateStatus"] = rep
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -312,13 +324,18 @@ func (a *Agent) fetchAndApplyConfig() error {
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
AgentUpdate *updateIntent `json:"agent_update"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One-click update intents ride this poll (item 20); handle them even
|
||||
// when the xray config itself is unchanged.
|
||||
a.maybeHandleUpdate(result.AgentUpdate)
|
||||
|
||||
// Skip if config hasn't changed
|
||||
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
)
|
||||
|
||||
// Item 20: the agent consumes the master's update intent, downloads the
|
||||
// release asset for its architecture, verifies the sha256 sidecar, swaps
|
||||
// the binary keeping a .bak, and re-execs. Failures must leave the old
|
||||
// binary running and produce a "failed" report.
|
||||
|
||||
func fakeReleaseServer(t *testing.T, payload []byte, goodSum bool, hits *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
asset, err := selfupdate.AssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
if !goodSum {
|
||||
sum = sha256.Sum256([]byte("something else"))
|
||||
}
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if hits != nil {
|
||||
hits.Add(1)
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, asset+".sha256"):
|
||||
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + " " + asset + "\n"))
|
||||
case strings.HasSuffix(r.URL.Path, asset):
|
||||
_, _ = w.Write(payload)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func newUpdateTestAgent(t *testing.T, masterURL string) (*Agent, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "xui-agent")
|
||||
if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &Agent{
|
||||
config: &Config{MasterURL: masterURL, Token: "tok", NodeName: "n"},
|
||||
httpClient: http.DefaultClient,
|
||||
exePathOverride: exe,
|
||||
}
|
||||
return a, exe
|
||||
}
|
||||
|
||||
func stubExec(t *testing.T, err error) *atomic.Int32 {
|
||||
t.Helper()
|
||||
calls := &atomic.Int32{}
|
||||
orig := execSelf
|
||||
execSelf = func(string) error {
|
||||
calls.Add(1)
|
||||
return err
|
||||
}
|
||||
t.Cleanup(func() { execSelf = orig })
|
||||
return calls
|
||||
}
|
||||
|
||||
func TestAgentUpdateSwapsAndRestarts(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, nil)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
execCalls := stubExec(t, nil)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 42, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "new-binary" {
|
||||
t.Fatalf("exe after update = %q, want new-binary", got)
|
||||
}
|
||||
bak, _ := os.ReadFile(exe + ".bak")
|
||||
if string(bak) != "old-binary" {
|
||||
t.Fatalf(".bak = %q, want old-binary", bak)
|
||||
}
|
||||
if execCalls.Load() != 1 {
|
||||
t.Fatalf("execSelf calls = %d, want 1", execCalls.Load())
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "swapped" {
|
||||
t.Fatalf("report = %+v, want swapped", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateBadChecksumKeepsOldBinary(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), false, nil)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
execCalls := stubExec(t, nil)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 43, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe after failed update = %q, want old-binary", got)
|
||||
}
|
||||
if _, err := os.Stat(exe + ".bak"); !os.IsNotExist(err) {
|
||||
t.Fatal("no .bak may be created when verification fails")
|
||||
}
|
||||
if execCalls.Load() != 0 {
|
||||
t.Fatal("execSelf must not run when verification fails")
|
||||
}
|
||||
rep := a.currentUpdateReport()
|
||||
if rep == nil || rep.State != "failed" || !strings.Contains(rep.Error, "sha256 mismatch") {
|
||||
t.Fatalf("report = %+v, want failed with sha256 mismatch", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateAlreadyCurrentSkipsDownload(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, &hits)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
stubExec(t, nil)
|
||||
|
||||
// The test binary's display version defaults to A1.0.0.
|
||||
a.runUpdate(updateIntent{RequestedAt: 44, Target: "A1.0.0", Tag: "V1.0.0", BaseURL: srv.URL + "/"})
|
||||
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("release server hit %d times for an already-current agent", hits.Load())
|
||||
}
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe = %q, want untouched old-binary", got)
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "current" {
|
||||
t.Fatalf("report = %+v, want current", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentUpdateExecFailureRollsBack(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, []byte("new-binary"), true, nil)
|
||||
defer srv.Close()
|
||||
a, exe := newUpdateTestAgent(t, srv.URL)
|
||||
stubExec(t, errExecBoom)
|
||||
|
||||
a.runUpdate(updateIntent{RequestedAt: 45, Target: "A1.0.1", Tag: "V1.0.1", BaseURL: srv.URL + "/"})
|
||||
|
||||
got, _ := os.ReadFile(exe)
|
||||
if string(got) != "old-binary" {
|
||||
t.Fatalf("exe after exec failure = %q, want rolled-back old-binary", got)
|
||||
}
|
||||
if rep := a.currentUpdateReport(); rep == nil || rep.State != "failed" {
|
||||
t.Fatalf("report = %+v, want failed", rep)
|
||||
}
|
||||
}
|
||||
|
||||
var errExecBoom = &execBoomError{}
|
||||
|
||||
type execBoomError struct{}
|
||||
|
||||
func (*execBoomError) Error() string { return "exec boom" }
|
||||
Reference in New Issue
Block a user