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 resolved externally usable host addresses for // agent nodes (P13 telemetry, public-address resolution on top). // 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 with no known address yet. 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 " panel-version label, host uptime, node->master latency, // CPU/memory percentages and the host IPv4/IPv6 pair. The address pair is // the resolved externally usable one (ResolveAgentAddresses), not the raw // telemetry — a NAT'd agent only ever reports its private interface // address. 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 { continue } // Address resolution applies to every bound agent, even before // its first telemetry report: an agent that has only registered // already has a master-observed source address. v.Ipv4, v.Ipv6 = ResolveAgentAddresses(ag) if 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 } } // 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 }