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:
@@ -9,9 +9,11 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"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/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
@@ -177,6 +179,73 @@ func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// heartbeatUpdateStatus is the agent's report about a one-click update
|
||||
// intent (item 20), piggybacked on the heartbeat like telemetry.
|
||||
type heartbeatUpdateStatus struct {
|
||||
RequestedAt int64 `json:"requestedAt"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// applyAgentUpdateStatus folds an agent's update report into the stored
|
||||
// intent. Reports for a superseded intent (requested_at mismatch while an
|
||||
// intent is pending) are ignored. Terminal reports (failed / current)
|
||||
// clear the intent so the config poll stops re-offering it; the recorded
|
||||
// state stays visible on the node row.
|
||||
func (a *AgentController) applyAgentUpdateStatus(id uint, st *heartbeatUpdateStatus) {
|
||||
db := database.GetDB()
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if row.UpdateRequestedAt == 0 || st.RequestedAt != row.UpdateRequestedAt {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"update_state_at": time.Now().UnixMilli()}
|
||||
switch st.State {
|
||||
case "downloading", "swapped":
|
||||
updates["update_state"] = st.State
|
||||
updates["update_error"] = ""
|
||||
case "failed":
|
||||
updates["update_state"] = "failed"
|
||||
updates["update_error"] = st.Error
|
||||
updates["update_requested_at"] = int64(0)
|
||||
case "current":
|
||||
updates["update_state"] = "current"
|
||||
updates["update_error"] = ""
|
||||
updates["update_requested_at"] = int64(0)
|
||||
default:
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// confirmAgentUpdate marks a pending intent successful once the agent
|
||||
// reports an A-series version at or beyond the intent's target — the new
|
||||
// version appearing is the end-to-end success signal of the update.
|
||||
func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) {
|
||||
if reportedVersion == "" {
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
var row model.AgentNode
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if row.UpdateRequestedAt == 0 || row.UpdateTarget == "" {
|
||||
return
|
||||
}
|
||||
if cmp, ok := config.CompareDisplayVersion(reportedVersion, row.UpdateTarget); !ok || cmp < 0 {
|
||||
return
|
||||
}
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"update_state": "success",
|
||||
"update_error": "",
|
||||
"update_requested_at": int64(0),
|
||||
"update_state_at": time.Now().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
@@ -186,13 +255,22 @@ func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
// Telemetry is optional and best-effort: a body that fails to parse
|
||||
// (or no body at all) must never fail the heartbeat itself.
|
||||
var req struct {
|
||||
Telemetry *heartbeatTelemetry `json:"telemetry"`
|
||||
Telemetry *heartbeatTelemetry `json:"telemetry"`
|
||||
UpdateStatus *heartbeatUpdateStatus `json:"updateStatus"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
a.touchAgent(uint(agent.Id), "")
|
||||
reportedVersion := agent.AgentVersion
|
||||
if req.Telemetry != nil {
|
||||
a.updateAgentTelemetry(uint(agent.Id), req.Telemetry)
|
||||
if req.Telemetry.AgentVersion != nil && *req.Telemetry.AgentVersion != "" {
|
||||
reportedVersion = *req.Telemetry.AgentVersion
|
||||
}
|
||||
}
|
||||
if req.UpdateStatus != nil {
|
||||
a.applyAgentUpdateStatus(uint(agent.Id), req.UpdateStatus)
|
||||
}
|
||||
a.confirmAgentUpdate(uint(agent.Id), reportedVersion)
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
@@ -218,10 +296,22 @@ func (a *AgentController) getConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
c.JSON(200, gin.H{
|
||||
payload := gin.H{
|
||||
"config_hash": hex.EncodeToString(sum[:]),
|
||||
"xray_config": json.RawMessage(raw),
|
||||
})
|
||||
}
|
||||
// Item 20: a pending one-click update intent rides along on the config
|
||||
// poll (no new channel — the agent already polls this endpoint).
|
||||
if agent.UpdateRequestedAt > 0 &&
|
||||
(agent.UpdateState == "pending" || agent.UpdateState == "downloading") {
|
||||
payload["agent_update"] = gin.H{
|
||||
"requested_at": agent.UpdateRequestedAt,
|
||||
"target": agent.UpdateTarget,
|
||||
"tag": agent.UpdateTag,
|
||||
"base_url": selfupdate.BaseURL(),
|
||||
}
|
||||
}
|
||||
c.JSON(200, payload)
|
||||
}
|
||||
|
||||
type agentTrafficReport struct {
|
||||
|
||||
Reference in New Issue
Block a user