Files
3x-ui/internal/web/runtime/manager.go
T
Sanaei bc49c1a68f fix(node): release a deleted node's metric series and HTTP client
Deleting a node must free what the master keeps per node in memory.

Delete dropped the node's cpu and mem series but not netUp and netDown,
which the heartbeat records too, so each deleted node leaked two tiered
histories. It now drops every NodeMetricKeys entry.

InvalidateNode, called on node edit, disable and delete, cleared only the
cached Remote. The pooled HTTP client and its transport stayed cached until
a later call for the same node pruned them, which a deleted node never
makes. InvalidateNode now drops those too, outside the manager lock; an
edited node pays one fresh handshake on its next call.
2026-09-15 20:39:32 +02:00

207 lines
4.6 KiB
Go

package runtime
import (
"errors"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type NodeEgressResolver interface {
NodeEgressProxyURL(nodeID int) string
}
type Manager struct {
local Runtime
mu sync.RWMutex
remotes map[int]*Remote
overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub
localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub
egressResolver NodeEgressResolver
}
func NewManager(localDeps LocalDeps) *Manager {
return &Manager{
local: NewLocal(localDeps),
remotes: make(map[int]*Remote),
}
}
// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of building a
// real Remote. Test seam for exercising node-dispatch paths without a network
// node; pass nil rt to clear.
func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
m.mu.Lock()
defer m.mu.Unlock()
if rt == nil {
delete(m.overrides, nodeID)
return
}
if m.overrides == nil {
m.overrides = make(map[int]Runtime)
}
m.overrides[nodeID] = rt
}
// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the real
// local runtime. Test seam for exercising the local dispatch path (MTProto
// sidecar, local Xray) without a running child process; pass nil rt to clear.
func (m *Manager) SetLocalRuntimeOverride(rt Runtime) {
m.mu.Lock()
defer m.mu.Unlock()
m.localOverride = rt
}
func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
m.mu.Lock()
defer m.mu.Unlock()
m.egressResolver = r
}
func (m *Manager) NodeEgressProxyURL(nodeID int) string {
m.mu.RLock()
defer m.mu.RUnlock()
if m.egressResolver == nil {
return ""
}
return m.egressResolver.NodeEgressProxyURL(nodeID)
}
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
if nodeID == nil {
m.mu.RLock()
if m.localOverride != nil {
rt := m.localOverride
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
return m.local, nil
}
m.mu.RLock()
if rt, ok := m.overrides[*nodeID]; ok {
m.mu.RUnlock()
return rt, nil
}
if rt, ok := m.remotes[*nodeID]; ok {
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if rt, ok := m.remotes[*nodeID]; ok {
return rt, nil
}
n, err := loadNode(*nodeID)
if err != nil {
return nil, err
}
if !n.Enable {
return nil, errors.New("node " + n.Name + " is disabled")
}
rt := NewRemote(n, m.egressResolver)
m.remotes[*nodeID] = rt
return rt, nil
}
func (m *Manager) Local() Runtime { return m.local }
func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
if node == nil {
return nil, errors.New("node is nil")
}
m.mu.RLock()
if rt, ok := m.remotes[node.Id]; ok {
if sameRemoteIdentity(rt.node, node) {
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
} else {
m.mu.RUnlock()
}
m.mu.Lock()
defer m.mu.Unlock()
if rt, ok := m.remotes[node.Id]; ok {
if sameRemoteIdentity(rt.node, node) {
return rt, nil
}
} else {
rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
m.remotes[node.Id] = rt
return rt, nil
}
rt := NewRemote(cloneRemoteNode(node), m.egressResolver)
m.remotes[node.Id] = rt
return rt, nil
}
func cloneRemoteNode(n *model.Node) *model.Node {
if n == nil {
return nil
}
clone := *n
if n.InboundTags != nil {
clone.InboundTags = append([]string(nil), n.InboundTags...)
}
return &clone
}
func sameRemoteIdentity(a, b *model.Node) bool {
if a == nil || b == nil {
return a == b
}
return a.Id == b.Id &&
a.Scheme == b.Scheme &&
a.Address == b.Address &&
a.Port == b.Port &&
a.BasePath == b.BasePath &&
a.ApiToken == b.ApiToken &&
a.AllowPrivateAddress == b.AllowPrivateAddress &&
a.TlsVerifyMode == b.TlsVerifyMode &&
a.PinnedCertSha256 == b.PinnedCertSha256 &&
a.OutboundTag == b.OutboundTag
}
// InvalidateNode forgets everything cached for a node, its pooled HTTP client too:
// only a later call for that node would prune it, and a deleted node never makes one.
func (m *Manager) InvalidateNode(nodeID int) {
m.mu.Lock()
delete(m.remotes, nodeID)
m.mu.Unlock()
nodeClientsMu.Lock()
dropNodeClients(nodeID, "")
nodeClientsMu.Unlock()
}
func loadNode(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
return nil, err
}
return n, nil
}
var (
managerMu sync.RWMutex
manager *Manager
)
func SetManager(m *Manager) {
managerMu.Lock()
defer managerMu.Unlock()
manager = m
}
func GetManager() *Manager {
managerMu.RLock()
defer managerMu.RUnlock()
return manager
}