package service import ( "strings" "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"` 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 " 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 != "" { v.PanelVersion = "agent " + ag.AgentVersion } 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 }