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:
@@ -17,10 +17,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
@@ -907,14 +909,54 @@ type NodeUpdateResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// fetchLatestReleaseTag resolves the newest release tag of the fork's own
|
||||
// repository; a package variable so tests can stub the network.
|
||||
var fetchLatestReleaseTag = func(ctx context.Context) (string, error) {
|
||||
return selfupdate.FetchLatestReleaseTag(ctx, &http.Client{Timeout: 20 * time.Second})
|
||||
}
|
||||
|
||||
// requestAgentUpdate records a one-click update intent for an agent node
|
||||
// (item 20). The agent picks the intent up from its next config poll,
|
||||
// downloads + verifies the release binary for the latest tag, swaps and
|
||||
// restarts itself; the outcome comes back on heartbeats. The dev channel
|
||||
// has no meaning for agents and is ignored.
|
||||
func (s *NodeService) requestAgentUpdate(ctx context.Context, n *model.Node) error {
|
||||
db := database.GetDB()
|
||||
var ag model.AgentNode
|
||||
if err := db.Where("node_id = ?", n.Id).Order("last_seen DESC, id DESC").First(&ag).Error; err != nil {
|
||||
return errors.New("no agent bound to this node")
|
||||
}
|
||||
tag, err := fetchLatestReleaseTag(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check latest release: %w", err)
|
||||
}
|
||||
parts, ok := config.ParseDisplayVersion(tag)
|
||||
if !ok {
|
||||
return fmt.Errorf("latest release tag %q is not a V-series version", tag)
|
||||
}
|
||||
target := fmt.Sprintf("A%d.%d.%d", parts[0], parts[1], parts[2])
|
||||
if cmp, ok := config.CompareDisplayVersion(ag.AgentVersion, target); ok && cmp >= 0 {
|
||||
return fmt.Errorf("agent already up to date (%s)", ag.AgentVersion)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
return db.Model(&model.AgentNode{}).Where("id = ?", ag.Id).Updates(map[string]any{
|
||||
"update_requested_at": now,
|
||||
"update_target": target,
|
||||
"update_tag": tag,
|
||||
"update_state": "pending",
|
||||
"update_error": "",
|
||||
"update_state_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePanels triggers the official self-updater on each given node. Only
|
||||
// enabled, online nodes are eligible — an offline node can't be reached, so it
|
||||
// is reported as skipped rather than silently dropped.
|
||||
//
|
||||
// Agent nodes take the item-20 intent path instead (see requestAgentUpdate):
|
||||
// the master cannot push to them, so "triggered" means the intent was
|
||||
// recorded for the agent's next poll.
|
||||
func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, error) {
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return nil, fmt.Errorf("runtime manager unavailable")
|
||||
}
|
||||
results := make([]NodeUpdateResult, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
n, err := s.GetById(id)
|
||||
@@ -928,7 +970,21 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err
|
||||
res.Error = "node is disabled"
|
||||
case n.Status != "online":
|
||||
res.Error = "node is offline"
|
||||
case s.IsAgentManaged(id):
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
err := s.requestAgentUpdate(ctx, n)
|
||||
cancel()
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
} else {
|
||||
res.OK = true
|
||||
}
|
||||
default:
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
res.Error = "runtime manager unavailable"
|
||||
break
|
||||
}
|
||||
remote, remoteErr := mgr.RemoteFor(n)
|
||||
if remoteErr != nil {
|
||||
res.Error = remoteErr.Error()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Item 20 (master side): UpdatePanels on an agent node records an update
|
||||
// intent on the bound agent row instead of pushing to a remote panel. The
|
||||
// intent targets the newest release tag (V-series) expressed as the
|
||||
// agent's A-series display version.
|
||||
func TestUpdatePanelsAgentIntent(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
origFetch := fetchLatestReleaseTag
|
||||
fetchLatestReleaseTag = func(context.Context) (string, error) { return "V1.0.1", nil }
|
||||
t.Cleanup(func() { fetchLatestReleaseTag = origFetch })
|
||||
|
||||
node := &model.Node{Id: 7501, Name: "agent-upd", Address: "", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ag := &model.AgentNode{
|
||||
TokenHash: "hash-upd", Name: "agent-upd", NodeID: &node.Id,
|
||||
AgentVersion: "A1.0.0",
|
||||
}
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
svc := &NodeService{}
|
||||
results, err := svc.UpdatePanels([]int{node.Id}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePanels: %v", err)
|
||||
}
|
||||
if len(results) != 1 || !results[0].OK {
|
||||
t.Fatalf("results = %+v, want one OK", results)
|
||||
}
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.UpdateState != "pending" || row.UpdateTarget != "A1.0.1" ||
|
||||
row.UpdateTag != "V1.0.1" || row.UpdateRequestedAt == 0 {
|
||||
t.Fatalf("intent not recorded: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePanelsAgentAlreadyCurrent(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
origFetch := fetchLatestReleaseTag
|
||||
fetchLatestReleaseTag = func(context.Context) (string, error) { return "V1.0.1", nil }
|
||||
t.Cleanup(func() { fetchLatestReleaseTag = origFetch })
|
||||
|
||||
node := &model.Node{Id: 7502, Name: "agent-cur", Address: "", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
ag := &model.AgentNode{
|
||||
TokenHash: "hash-cur", Name: "agent-cur", NodeID: &node.Id,
|
||||
AgentVersion: "A1.0.1",
|
||||
}
|
||||
if err := db.Create(ag).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
|
||||
svc := &NodeService{}
|
||||
results, err := svc.UpdatePanels([]int{node.Id}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePanels: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].OK || !strings.Contains(results[0].Error, "already up to date") {
|
||||
t.Fatalf("results = %+v, want not-OK already-up-to-date", results)
|
||||
}
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, ag.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.UpdateRequestedAt != 0 {
|
||||
t.Fatalf("no intent may be recorded for a current agent: %+v", row)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ type NodeView struct {
|
||||
// traditional nodes and for agents that never reported telemetry.
|
||||
Ipv4 string `json:"ipv4,omitempty" example:"203.0.113.7"`
|
||||
Ipv6 string `json:"ipv6,omitempty" example:"2001:db8::7"`
|
||||
// Agent one-click update bookkeeping (item 20), agent nodes only:
|
||||
// the last intent's state/target and, on failure, its error.
|
||||
AgentUpdateState string `json:"agentUpdateState,omitempty" example:"failed"`
|
||||
AgentUpdateError string `json:"agentUpdateError,omitempty" example:""`
|
||||
AgentUpdateTarget string `json:"agentUpdateTarget,omitempty" example:"A1.0.1"`
|
||||
Guid string `json:"guid" example:"node-guid"`
|
||||
Status string `json:"status" example:"online"`
|
||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
||||
@@ -208,6 +213,9 @@ func annotateAgentTelemetry(views []*NodeView) {
|
||||
} else {
|
||||
v.PanelVersion = "agent " + ag.AgentVersion
|
||||
}
|
||||
v.AgentUpdateState = ag.UpdateState
|
||||
v.AgentUpdateError = ag.UpdateError
|
||||
v.AgentUpdateTarget = ag.UpdateTarget
|
||||
}
|
||||
v.UptimeSecs = ag.UptimeSecs
|
||||
v.LatencyMs = ag.LatencyMs
|
||||
|
||||
Reference in New Issue
Block a user