NodeView carried only the category counts, so the nodes page chips could hover to nothing but the category title. GetAll/recountByGuid now emit online/active/disabled/depleted email lists from the same bucketing pass as the counts (statuses gain the client email; the online list is the counted set), and the chips pop the list over with the inbounds page's popover pattern.
329 lines
13 KiB
Go
329 lines
13 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"`
|
|
// Per-category client email lists behind the counts above, from the
|
|
// same attribution bucketing, so the nodes page category chips can
|
|
// pop over the actual usernames. Empty when the category is empty.
|
|
OnlineEmails []string `json:"onlineEmails,omitempty" example:"[\"a@example.com\"]"`
|
|
ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"`
|
|
DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"`
|
|
DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"`
|
|
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,
|
|
OnlineEmails: n.OnlineEmails,
|
|
ActiveEmails: n.ActiveEmails,
|
|
DisabledEmails: n.DisabledEmails,
|
|
DepletedEmails: n.DepletedEmails,
|
|
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
|
|
}
|