354 lines
14 KiB
Go
354 lines
14 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.
|
|
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:"/"`
|
|
Enable bool `json:"enable" example:"true"`
|
|
AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"`
|
|
// NodeType is "agent" for agent-managed nodes (an agent is bound to
|
|
// the node) and "traditional" for a legacy row with no binding. 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:"agent"`
|
|
// 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"`
|
|
// ProbeOnly marks an agent node as probe-only (V2.1.0): no core
|
|
// runs on it, only the probe channels. Overlaid from the bound
|
|
// agent row like the update bookkeeping below; false (and absent)
|
|
// for traditional nodes and unflagged agents.
|
|
ProbeOnly bool `json:"probeOnly,omitempty" example:"false"`
|
|
// 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"`
|
|
// AgentOS is the bound agent's probe-reported OS (AgentNode.ProbeOS,
|
|
// e.g. "Windows 10" from the frozen Windows agent, "Debian GNU/Linux"
|
|
// from Linux agents). The nodes page uses it only to suppress the
|
|
// agent-update badge on Windows rows: Windows agents have no
|
|
// one-click update (no win assets ship, the frozen A2.2.1 agent
|
|
// skips intents), so lighting the badge there is a false promise.
|
|
// Empty for agents that never reported probe facts; callers must
|
|
// treat empty as "not Windows" (fail open), never as Windows.
|
|
AgentOS string `json:"agentOs,omitempty" example:"Windows 10"`
|
|
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"`
|
|
// SingboxVersion is the bound agent's sing-box core version
|
|
// (V2.0.1 ②), overlaid from agent telemetry like XrayVersion;
|
|
// empty for traditional nodes and agents that never reported
|
|
// one. The nodes page prefers it over XrayVersion when set.
|
|
SingboxVersion string `json:"singboxVersion" example:"1.13.21"`
|
|
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\"]"`
|
|
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,
|
|
Enable: n.Enable,
|
|
AllowPrivateAddress: n.AllowPrivateAddress,
|
|
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,
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
|
|
// isWindowsAgentOS reports whether a probe-reported OS string names
|
|
// Windows. winOSName() returns the registry ProductName ("Windows 10",
|
|
// "Windows 11", …) with DisplayVersion appended, falling back to the
|
|
// bare "Windows", so a case-insensitive "windows" prefix is the whole
|
|
// test. Empty fails open (false): no probe facts yet, not Windows.
|
|
func isWindowsAgentOS(osName string) bool {
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(osName)), "windows")
|
|
}
|
|
|
|
// agentVersionCell renders one agent's reported version for the
|
|
// node list's panel-version cell, uniformly across the fleet:
|
|
// - current agents report the A-series display version and are
|
|
// shown bare ("A2.1.0");
|
|
// - agents built while the agent version was still derived from
|
|
// the panel's injected number could report the doubled "AV2.1.0"
|
|
// and are repaired to the bare A-form here, so those nodes read
|
|
// correctly without waiting for their next agent update (the
|
|
// derivation itself is gone — config.GetAgentDisplayVersion now
|
|
// reads the agent line's own source);
|
|
// - pre-display-version agents report the internal build version
|
|
// and keep the historical "agent " prefix.
|
|
func agentVersionCell(reported string) string {
|
|
s := strings.TrimSpace(reported)
|
|
s = strings.TrimPrefix(s, "agent ")
|
|
if len(s) > 2 && (s[0] == 'A' || s[0] == 'a') && (s[1] == 'V' || s[1] == 'v') {
|
|
if _, ok := config.ParseDisplayVersion(s[1:]); ok {
|
|
return "A" + s[2:]
|
|
}
|
|
}
|
|
if _, ok := config.ParseDisplayVersion(s); ok && strings.HasPrefix(s, "A") {
|
|
return s
|
|
}
|
|
return "agent " + s
|
|
}
|
|
|
|
// 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. 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)
|
|
// Probe-only (V2.1.0) is a stored flag, not telemetry: it must
|
|
// show even before the agent's first report, so it is overlaid
|
|
// here, ahead of the telemetry early-exit below.
|
|
v.ProbeOnly = ag.ProbeOnly
|
|
// Same for the probe OS: register/heartbeat probe facts carry
|
|
// it independently of the telemetry block, so a Windows row
|
|
// must be identifiable as Windows even before TelemetryAt.
|
|
v.AgentOS = ag.ProbeOS
|
|
if ag.TelemetryAt == 0 {
|
|
continue
|
|
}
|
|
if ag.XrayVersion != "" {
|
|
v.XrayVersion = ag.XrayVersion
|
|
}
|
|
if ag.SingboxVersion != "" {
|
|
v.SingboxVersion = ag.SingboxVersion
|
|
}
|
|
if ag.AgentVersion != "" {
|
|
v.PanelVersion = agentVersionCell(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 contract. Every node is
|
|
// agent-managed: it dials out to the master and authenticates with the
|
|
// agent token issued on creation, so the request carries no panel
|
|
// endpoint credentials.
|
|
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"`
|
|
Enable bool `json:"enable" form:"enable"`
|
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"`
|
|
// ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A
|
|
// pointer so updates can tell "leave the flag alone" (nil) from an
|
|
// explicit on/off; applied to the bound agent rows. On create it
|
|
// presets the flag on the freshly issued agent.
|
|
ProbeOnly *bool `json:"probeOnly,omitempty" form:"probeOnly"`
|
|
}
|
|
|
|
func (r *NodeMutationRequest) validate() error {
|
|
if r == nil {
|
|
return common.NewError("node request is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isAgentPlaceholderAddress reports whether addr is the placeholder the
|
|
// node form submits for agent nodes ("agent": they dial out to the master
|
|
// and have no panel endpoint to store).
|
|
func isAgentPlaceholderAddress(addr string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(addr), "agent")
|
|
}
|
|
|
|
// sanitizeAgentEndpoint rewrites an agent-node write's endpoint fields so
|
|
// the form placeholder never reaches the nodes table: a placeholder (or
|
|
// empty) address is replaced by the node's currently resolved address —
|
|
// "" when none is known yet (a new node, or an agent that never
|
|
// reported) — and the port is dropped to 0, since an agent node has no
|
|
// panel port either. A real address submitted for an agent node is kept
|
|
// verbatim, port included.
|
|
func sanitizeAgentEndpoint(req *NodeMutationRequest, resolved string) {
|
|
addr := strings.TrimSpace(req.Address)
|
|
if addr != "" && !isAgentPlaceholderAddress(addr) {
|
|
return
|
|
}
|
|
req.Address = resolved
|
|
req.Port = 0
|
|
}
|
|
|
|
func (r *NodeMutationRequest) toNode() *model.Node {
|
|
return &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,
|
|
}
|
|
}
|