Files
3x-ui-agent/internal/web/service/node_contract.go
T
Muse f887f47bb9 P7: restore agent node type, agent heartbeat expiry, build-version injection, remove log/history/xray-metrics modules, optional sub port
- nodes: port node-type (traditional/agent) picker back from web-src into
  the embedded frontend; backend annotates NodeView.nodeType from
  agent_nodes bindings; fix agent-node update path (skip reachability
  probe and apiToken requirement for agent-managed nodes); restore i18n
  keys in all 13 locales; install command now uses the Gitea mirror
- jobs: expire agent-bound nodes whose agent_nodes.last_seen is older
  than max(3x heartbeat interval, 120s) = 180s; last_seen=0 (never
  registered) is left untouched; transitions reuse UpdateHeartbeat +
  node event broadcast
- config: GetBaseVersion honors ldflags-injected buildVersion; release
  builds inject 3.7.0-agent.1 so the panel no longer reports a phantom
  update against the Gitea release tag
- logger/server: remove the three log viewers (panel/xray/amneziawg),
  their API endpoints and the 10240-entry in-memory log buffer; file
  logging via lumberjack unchanged; tests assert via a new optional
  logger hook instead of the removed buffer
- server: remove system history and xray metrics modules end to end
  (endpoints, sampling appends in RefreshStatus, per-minute persist
  cron, gob snapshot/restore, XrayMetricsService); realtime status and
  per-node history are untouched
- settings: subPort may be 0/unset - subscription links are generated
  without a port while the subscription service keeps binding the
  default 2096; the settings form declares the cleared state via
  onNumber's new onClear option instead of snapping back to 1
2026-10-21 06:44:00 +00:00

248 lines
9.8 KiB
Go

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"`
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"
}
}
}
// 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
}