- 新增 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
318 lines
12 KiB
Go
318 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"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/util/common"
|
|
)
|
|
|
|
// NodeView is the browser/API read contract for nodes. Credentials are
|
|
// write-only: responses expose only whether a node has a token configured.
|
|
type NodeView struct {
|
|
Id int `json:"id" example:"1"`
|
|
Name string `json:"name" example:"edge-1"`
|
|
Remark string `json:"remark" example:"Primary edge"`
|
|
Scheme string `json:"scheme" example:"https"`
|
|
Address string `json:"address" example:"node.example.com"`
|
|
Port int `json:"port" example:"2053"`
|
|
BasePath string `json:"basePath" example:"/"`
|
|
HasApiToken bool `json:"hasApiToken" example:"true"`
|
|
Enable bool `json:"enable" example:"true"`
|
|
AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"`
|
|
TlsVerifyMode string `json:"tlsVerifyMode" example:"verify"`
|
|
PinnedCertSha256 string `json:"pinnedCertSha256" example:""`
|
|
InboundSyncMode string `json:"inboundSyncMode" example:"all"`
|
|
InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
|
|
OutboundTag string `json:"outboundTag" example:"direct"`
|
|
// NodeType is "agent" for lightweight agent-managed nodes (an agent is
|
|
// bound to the node) and "traditional" otherwise. It is derived from the
|
|
// agent binding, not stored on the node row; read paths that serve the
|
|
// panel UI annotate it via annotateNodeTypes. Empty on views built
|
|
// outside those paths.
|
|
NodeType string `json:"nodeType,omitempty" example:"traditional"`
|
|
// Ipv4/Ipv6 carry the agent-reported host addresses for agent nodes
|
|
// (P13). The nodes page renders them as plain text on agent rows
|
|
// instead of the panel-URL link used for traditional nodes. Empty for
|
|
// 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"`
|
|
LatencyMs int `json:"latencyMs" example:"42"`
|
|
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
|
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
|
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
|
MemPct float64 `json:"memPct" example:"45.2"`
|
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
|
NetUp uint64 `json:"netUp" example:"2097152"`
|
|
NetDown uint64 `json:"netDown" example:"1048576"`
|
|
LastError string `json:"lastError" example:""`
|
|
XrayState string `json:"xrayState" example:"running"`
|
|
XrayError string `json:"xrayError" example:""`
|
|
ConfigDirty bool `json:"configDirty" example:"false"`
|
|
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
|
|
InboundCount int `json:"inboundCount" example:"3"`
|
|
ClientCount int `json:"clientCount" example:"25"`
|
|
OnlineCount int `json:"onlineCount" example:"5"`
|
|
ActiveCount int `json:"activeCount" example:"20"`
|
|
DisabledCount int `json:"disabledCount" example:"2"`
|
|
DepletedCount int `json:"depletedCount" example:"1"`
|
|
ParentGuid string `json:"parentGuid,omitempty" example:""`
|
|
Transitive bool `json:"transitive,omitempty" example:"false"`
|
|
CreatedAt int64 `json:"createdAt" example:"1700000000"`
|
|
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
|
|
}
|
|
|
|
func toNodeView(n *model.Node) *NodeView {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
return &NodeView{
|
|
Id: n.Id,
|
|
Name: n.Name,
|
|
Remark: n.Remark,
|
|
Scheme: n.Scheme,
|
|
Address: n.Address,
|
|
Port: n.Port,
|
|
BasePath: n.BasePath,
|
|
HasApiToken: n.ApiToken != "",
|
|
Enable: n.Enable,
|
|
AllowPrivateAddress: n.AllowPrivateAddress,
|
|
TlsVerifyMode: n.TlsVerifyMode,
|
|
PinnedCertSha256: n.PinnedCertSha256,
|
|
InboundSyncMode: n.InboundSyncMode,
|
|
InboundTags: n.InboundTags,
|
|
OutboundTag: n.OutboundTag,
|
|
Guid: n.Guid,
|
|
Status: n.Status,
|
|
LastHeartbeat: n.LastHeartbeat,
|
|
LatencyMs: n.LatencyMs,
|
|
XrayVersion: n.XrayVersion,
|
|
PanelVersion: n.PanelVersion,
|
|
CpuPct: n.CpuPct,
|
|
MemPct: n.MemPct,
|
|
UptimeSecs: n.UptimeSecs,
|
|
NetUp: n.NetUp,
|
|
NetDown: n.NetDown,
|
|
LastError: n.LastError,
|
|
XrayState: n.XrayState,
|
|
XrayError: n.XrayError,
|
|
ConfigDirty: n.ConfigDirty,
|
|
ConfigDirtyAt: n.ConfigDirtyAt,
|
|
InboundCount: n.InboundCount,
|
|
ClientCount: n.ClientCount,
|
|
OnlineCount: n.OnlineCount,
|
|
ActiveCount: n.ActiveCount,
|
|
DisabledCount: n.DisabledCount,
|
|
DepletedCount: n.DepletedCount,
|
|
ParentGuid: n.ParentGuid,
|
|
Transitive: n.Transitive,
|
|
CreatedAt: n.CreatedAt,
|
|
UpdatedAt: n.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func toNodeViews(nodes []*model.Node) []*NodeView {
|
|
views := make([]*NodeView, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
views = append(views, toNodeView(node))
|
|
}
|
|
return views
|
|
}
|
|
|
|
// agentManagedNodeIDs returns the set of node ids with at least one agent
|
|
// bound to them — the definition of an "agent node" in this codebase (the
|
|
// heartbeat job's probe skip uses the same test).
|
|
func agentManagedNodeIDs() map[int]bool {
|
|
ids := map[int]bool{}
|
|
var rows []int
|
|
_ = database.GetDB().Model(&model.AgentNode{}).
|
|
Where("node_id IS NOT NULL").
|
|
Distinct().
|
|
Pluck("node_id", &rows).Error
|
|
for _, id := range rows {
|
|
ids[id] = true
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// IsAgentManaged reports whether the node has an agent bound to it.
|
|
func (s *NodeService) IsAgentManaged(id int) bool {
|
|
var count int64
|
|
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", id).Count(&count).Error
|
|
return count > 0
|
|
}
|
|
|
|
// annotateNodeTypes fills NodeType on views from the agent bindings so the
|
|
// UI can tell agent nodes apart from traditional panel nodes when editing.
|
|
func annotateNodeTypes(views []*NodeView) {
|
|
if len(views) == 0 {
|
|
return
|
|
}
|
|
agents := agentManagedNodeIDs()
|
|
for _, v := range views {
|
|
if agents[v.Id] {
|
|
v.NodeType = "agent"
|
|
} else {
|
|
v.NodeType = "traditional"
|
|
}
|
|
}
|
|
}
|
|
|
|
// annotateAgentTelemetry overlays the latest heartbeat telemetry reported
|
|
// by each node's bound agent onto its view (P13): xray version, the
|
|
// "agent <version>" panel-version label, host uptime, node->master latency,
|
|
// CPU/memory percentages and the host IPv4/IPv6 pair. Views for traditional
|
|
// nodes (and agent nodes whose agent never reported telemetry) are left
|
|
// exactly as built. Must run after annotateNodeTypes.
|
|
func annotateAgentTelemetry(views []*NodeView) {
|
|
if len(views) == 0 {
|
|
return
|
|
}
|
|
var agents []model.AgentNode
|
|
// Latest reporter wins when more than one token is bound to a node.
|
|
_ = database.GetDB().Model(&model.AgentNode{}).
|
|
Where("node_id IS NOT NULL").
|
|
Order("last_seen DESC").
|
|
Find(&agents).Error
|
|
byNode := make(map[int]*model.AgentNode, len(agents))
|
|
for i := range agents {
|
|
if agents[i].NodeID == nil {
|
|
continue
|
|
}
|
|
if _, ok := byNode[*agents[i].NodeID]; !ok {
|
|
byNode[*agents[i].NodeID] = &agents[i]
|
|
}
|
|
}
|
|
for _, v := range views {
|
|
if v.NodeType != "agent" {
|
|
continue
|
|
}
|
|
ag, ok := byNode[v.Id]
|
|
if !ok || ag.TelemetryAt == 0 {
|
|
continue
|
|
}
|
|
if ag.XrayVersion != "" {
|
|
v.XrayVersion = ag.XrayVersion
|
|
}
|
|
if ag.AgentVersion != "" {
|
|
// New agents report the A-series display version, shown
|
|
// verbatim; older agents report the internal build version
|
|
// and keep the historical "agent " prefix.
|
|
if _, ok := config.ParseDisplayVersion(ag.AgentVersion); ok && strings.HasPrefix(ag.AgentVersion, "A") {
|
|
v.PanelVersion = ag.AgentVersion
|
|
} 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
|
|
v.CpuPct = ag.CpuPct
|
|
v.MemPct = ag.MemPct
|
|
v.Ipv4 = ag.IPv4
|
|
v.Ipv6 = ag.IPv6
|
|
}
|
|
}
|
|
|
|
// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
|
|
// only as input. On update, nil means keep the stored token; replacement and
|
|
// clearing are explicit and mutually exclusive.
|
|
type NodeMutationRequest struct {
|
|
Id int `json:"id" form:"id"`
|
|
Name string `json:"name" form:"name" validate:"required"`
|
|
Remark string `json:"remark" form:"remark"`
|
|
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
|
Address string `json:"address" form:"address"`
|
|
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
|
BasePath string `json:"basePath" form:"basePath"`
|
|
ApiToken *string `json:"apiToken,omitempty" form:"apiToken"`
|
|
ClearApiToken bool `json:"clearApiToken,omitempty" form:"clearApiToken"`
|
|
Enable bool `json:"enable" form:"enable"`
|
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"`
|
|
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" validate:"omitempty,oneof=verify skip pin mtls"`
|
|
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256"`
|
|
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"`
|
|
InboundTags []string `json:"inboundTags" form:"inboundTags"`
|
|
OutboundTag string `json:"outboundTag" form:"outboundTag"`
|
|
// NodeType distinguishes traditional panel-to-panel nodes from lightweight
|
|
// agent nodes. Agent nodes skip the panel reachability probe and the API
|
|
// token requirement; an agent token is issued on creation instead.
|
|
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"`
|
|
}
|
|
|
|
func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
|
if r == nil {
|
|
return common.NewError("node request is required")
|
|
}
|
|
// Agent nodes dial out to the master; address/port are not needed.
|
|
// Traditional nodes still require them.
|
|
if r.NodeType != "agent" {
|
|
if strings.TrimSpace(r.Address) == "" {
|
|
return common.NewError("address is required")
|
|
}
|
|
if r.Port < 1 || r.Port > 65535 {
|
|
return common.NewError("port must be between 1 and 65535")
|
|
}
|
|
}
|
|
if r.ApiToken != nil && r.ClearApiToken {
|
|
return common.NewError("apiToken and clearApiToken are mutually exclusive")
|
|
}
|
|
if r.ApiToken != nil {
|
|
*r.ApiToken = strings.TrimSpace(*r.ApiToken)
|
|
if *r.ApiToken == "" {
|
|
if create {
|
|
return common.NewError("apiToken is required unless mtls is enabled")
|
|
}
|
|
r.ApiToken = nil
|
|
}
|
|
}
|
|
if create {
|
|
if r.ClearApiToken {
|
|
return common.NewError("credentials cannot be cleared while creating a node")
|
|
}
|
|
// Agent nodes authenticate via agent token, not panel API token.
|
|
if r.NodeType != "agent" && r.ApiToken == nil && r.TlsVerifyMode != "mtls" {
|
|
return common.NewError("apiToken is required unless mtls is enabled")
|
|
}
|
|
}
|
|
if r.ClearApiToken && r.Enable && r.TlsVerifyMode != "mtls" {
|
|
return common.NewError("disable the node or enable mtls before clearing its apiToken")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *NodeMutationRequest) toNode() *model.Node {
|
|
n := &model.Node{
|
|
Id: r.Id,
|
|
Name: r.Name,
|
|
Remark: r.Remark,
|
|
Scheme: r.Scheme,
|
|
Address: r.Address,
|
|
Port: r.Port,
|
|
BasePath: r.BasePath,
|
|
Enable: r.Enable,
|
|
AllowPrivateAddress: r.AllowPrivateAddress,
|
|
TlsVerifyMode: r.TlsVerifyMode,
|
|
PinnedCertSha256: r.PinnedCertSha256,
|
|
InboundSyncMode: r.InboundSyncMode,
|
|
InboundTags: r.InboundTags,
|
|
OutboundTag: r.OutboundTag,
|
|
}
|
|
if r.ApiToken != nil {
|
|
n.ApiToken = *r.ApiToken
|
|
}
|
|
return n
|
|
}
|