mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
679d2e1cca
* fix(node): never re-add a node's full counter on reset/restart (#5456, #5476, #5390) When a node's per-client counter dips below the master's stored baseline (node reboot, xray restart, or a reset propagated to the node), the delta accounting clamped delta to the node's whole current counter and re-added it to the master total — double-counting a client's lifetime usage in a single sync and often pushing them over quota. Treat a backward-moving counter as a reset: add 0 and rebaseline to the reported value, so only genuine post-reset usage accrues. Resets also now clear the per-node NodeClientTraffic baseline (ResetClient TrafficByEmail, resetClientTrafficLocked, BulkResetTraffic, resetAllClient TrafficsLocked), mirroring the delete paths. Without this the node's pre-reset cumulative — including traffic it had counted but not yet synced — leaks back onto the master after a reset, which is the 'reset reverts after a while' report. The next sync then takes the clean delta=0 + rebaseline path regardless of node state. Updates TestNodeCounterReset (was _Clamped, now _NoReAdd) to assert rebaseline instead of re-add, and adds TestCentralResetClearsNodeBaseline_NoLeak. * fix(inbound): keep persisted node share strategy on edit (#5375) Opening the edit modal silently reverted shareAddrStrategy from 'node' to 'listen'. The downgrade effect fires before the form settles: availableNodes is an empty placeholder until /nodes/list resolves, and Form.useWatch('protocol') is briefly empty on the first edit render — both transiently make the node option look unavailable, so the effect clobbered the saved value. Gate the downgrade on availableNodesFetched (threaded from useNodesQuery through InboundsPage) and on the protocol watch being settled, so a persisted strategy is only downgraded when the node option is genuinely unavailable. Adds a rerender-based regression test covering the nodes-loading race. * <3 * perf(traffic): skip cross-panel quota subquery when no globals exist (#5392, #5389) disableInvalidClients ran a correlated EXISTS against client_global_traffics on the full client_traffics table every 5s. On a panel no master pushes to, that table is empty so the subquery can never match — yet it forced a full scan that pegged Postgres at 100% CPU on large client counts. Probe the table first and drop the EXISTS branch when it's empty (the common case), and add an idx_client_global_email index so the subquery is an index lookup when globals are present. Cross-panel enforcement is unchanged (TestGlobalUsage_DisablesClient). This also relieves #5389 ('traffic writer queue full' / panel freeze): the heavy query runs inside the serialized traffic write, so a slow DB backs the shared writer queue up until request handlers block. * fix(sub): don't advertise a leaked client IP for local wildcard inbounds (#5425) For a local inbound with no node, no custom share address, and a wildcard/blank listen, resolveInboundAddress fell straight through to the subscriber's request host. Behind NAT/proxy/CDN that Host can be the requesting client's own IP, so the subscription wrote the client's address into the inbound instead of the server's — while the panel's own share link (which doesn't use the request host) stayed correct. Prefer the admin's configured public host (Sub/Web domain) over the raw request host for this last-resort fallback. With no configured host the request host still stands, so existing single-domain setups are unaffected.
273 lines
8.3 KiB
Go
273 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error) {
|
|
now := time.Now().Unix() * 1000
|
|
needRestart := false
|
|
|
|
if p != nil {
|
|
var tags []string
|
|
err := tx.Table("inbounds").
|
|
Select("inbounds.tag").
|
|
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
|
|
Scan(&tags).Error
|
|
if err != nil {
|
|
return false, 0, err
|
|
}
|
|
s.xrayApi.Init(p.GetAPIPort())
|
|
for _, tag := range tags {
|
|
err1 := s.xrayApi.DelInbound(tag)
|
|
if err1 == nil {
|
|
logger.Debug("Inbound disabled by api:", tag)
|
|
} else {
|
|
logger.Debug("Error in disabling inbound by api:", err1)
|
|
needRestart = true
|
|
}
|
|
}
|
|
s.xrayApi.Close()
|
|
}
|
|
|
|
result := tx.Model(model.Inbound{}).
|
|
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
|
|
Update("enable", false)
|
|
err := result.Error
|
|
count := result.RowsAffected
|
|
return needRestart, count, err
|
|
}
|
|
|
|
// depletedClientsCond matches clients that exhausted their quota or expired.
|
|
// Besides the local counters it also trips on the cross-panel usage a master
|
|
// pushed into client_global_traffics — that's what lets a node cut a client
|
|
// whose combined usage exceeds the quota even though the local share doesn't
|
|
// (placeholders: now).
|
|
const depletedClientsCond = `((total > 0 AND up + down >= total)
|
|
OR (expiry_time > 0 AND expiry_time <= ?)
|
|
OR (total > 0 AND EXISTS (
|
|
SELECT 1 FROM client_global_traffics g
|
|
WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
|
|
)))`
|
|
|
|
// depletedClientsCondLocal is depletedClientsCond without the cross-panel
|
|
// client_global_traffics check. The EXISTS branch is a correlated subquery that
|
|
// turns every traffic poll into a full client_traffics scan; on a panel no
|
|
// master pushes to (the common case) client_global_traffics is empty, so the
|
|
// branch can never match and is pure CPU cost (#5392).
|
|
const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
|
|
OR (expiry_time > 0 AND expiry_time <= ?))`
|
|
|
|
// depletedCond returns the local-only predicate unless this panel actually
|
|
// holds global-traffic rows, in which case the cross-panel EXISTS check is
|
|
// needed to enforce combined quota. Both variants take the same single
|
|
// expiry_time placeholder, so callers pass identical args either way.
|
|
func depletedCond(tx *gorm.DB) string {
|
|
var probe int64
|
|
if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
|
|
return depletedClientsCond
|
|
}
|
|
return depletedClientsCondLocal
|
|
}
|
|
|
|
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
|
|
now := time.Now().Unix() * 1000
|
|
needRestart := false
|
|
cond := depletedCond(tx)
|
|
|
|
var depletedRows []xray.ClientTraffic
|
|
err := tx.Model(xray.ClientTraffic{}).
|
|
Where(cond+" AND enable = ?", now, true).
|
|
Find(&depletedRows).Error
|
|
if err != nil {
|
|
return false, 0, nil, err
|
|
}
|
|
if len(depletedRows) == 0 {
|
|
return false, 0, nil, nil
|
|
}
|
|
|
|
depletedEmails := make([]string, 0, len(depletedRows))
|
|
for i := range depletedRows {
|
|
if depletedRows[i].Email == "" {
|
|
continue
|
|
}
|
|
depletedEmails = append(depletedEmails, depletedRows[i].Email)
|
|
}
|
|
|
|
type target struct {
|
|
InboundID int `gorm:"column:inbound_id"`
|
|
NodeID *int `gorm:"column:node_id"`
|
|
Tag string
|
|
Email string
|
|
}
|
|
var targets []target
|
|
if len(depletedEmails) > 0 {
|
|
err = tx.Raw(`
|
|
SELECT inbounds.id AS inbound_id, inbounds.node_id AS node_id,
|
|
inbounds.tag AS tag, clients.email AS email
|
|
FROM clients
|
|
JOIN client_inbounds ON client_inbounds.client_id = clients.id
|
|
JOIN inbounds ON inbounds.id = client_inbounds.inbound_id
|
|
WHERE clients.email IN ?
|
|
`, depletedEmails).Scan(&targets).Error
|
|
if err != nil {
|
|
return false, 0, nil, err
|
|
}
|
|
}
|
|
|
|
var localTargets []target
|
|
localByInbound := make(map[int]map[string]struct{})
|
|
remoteByInbound := make(map[int][]target)
|
|
for _, t := range targets {
|
|
if t.NodeID == nil {
|
|
localTargets = append(localTargets, t)
|
|
if localByInbound[t.InboundID] == nil {
|
|
localByInbound[t.InboundID] = make(map[string]struct{})
|
|
}
|
|
localByInbound[t.InboundID][t.Email] = struct{}{}
|
|
} else {
|
|
remoteByInbound[t.InboundID] = append(remoteByInbound[t.InboundID], t)
|
|
}
|
|
}
|
|
|
|
if p != nil && len(localTargets) > 0 {
|
|
s.xrayApi.Init(p.GetAPIPort())
|
|
for _, t := range localTargets {
|
|
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
|
|
if err1 == nil {
|
|
logger.Debug("Client disabled by api:", t.Email)
|
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", t.Email)) {
|
|
logger.Debug("User is already disabled. Nothing to do more...")
|
|
} else {
|
|
logger.Debug("Error in disabling client by api:", err1)
|
|
needRestart = true
|
|
}
|
|
}
|
|
s.xrayApi.Close()
|
|
}
|
|
|
|
for inboundID, emails := range localByInbound {
|
|
if _, _, mErr := s.markClientsDisabledInSettings(tx, inboundID, emails); mErr != nil {
|
|
logger.Warning("disableInvalidClients: settings.JSON sync failed for inbound", inboundID, ":", mErr)
|
|
}
|
|
}
|
|
|
|
result := tx.Model(xray.ClientTraffic{}).
|
|
Where(cond+" AND enable = ?", now, true).
|
|
Update("enable", false)
|
|
err = result.Error
|
|
count := result.RowsAffected
|
|
if err != nil {
|
|
return needRestart, count, nil, err
|
|
}
|
|
|
|
if len(depletedEmails) > 0 {
|
|
if err := tx.Model(&model.ClientRecord{}).
|
|
Where("email IN ?", depletedEmails).
|
|
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
|
|
logger.Warning("disableInvalidClients update clients.enable:", err)
|
|
}
|
|
}
|
|
|
|
disabledNodeIDs := make(map[int]struct{})
|
|
for inboundID, group := range remoteByInbound {
|
|
emails := make(map[string]struct{}, len(group))
|
|
for _, t := range group {
|
|
emails[t.Email] = struct{}{}
|
|
}
|
|
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
|
|
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
|
|
needRestart = true
|
|
} else {
|
|
for _, t := range group {
|
|
if t.NodeID != nil {
|
|
disabledNodeIDs[*t.NodeID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
nodeIDs := make([]int, 0, len(disabledNodeIDs))
|
|
for nodeID := range disabledNodeIDs {
|
|
nodeIDs = append(nodeIDs, nodeID)
|
|
}
|
|
|
|
return needRestart, count, nodeIDs, nil
|
|
}
|
|
|
|
// markClientsDisabledInSettings flips client.enable=false in the inbound's
|
|
// stored settings JSON for the given emails and returns both the pre and
|
|
// post snapshots so a caller pushing to a remote node has the diff to hand.
|
|
func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID int, emails map[string]struct{}) (oldIb, newIb *model.Inbound, err error) {
|
|
var ib model.Inbound
|
|
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).First(&ib).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
snapshot := ib
|
|
|
|
settings := map[string]any{}
|
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
clients, _ := settings["clients"].([]any)
|
|
now := time.Now().Unix() * 1000
|
|
mutated := false
|
|
for i := range clients {
|
|
entry, ok := clients[i].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
email, _ := entry["email"].(string)
|
|
if _, hit := emails[email]; !hit {
|
|
continue
|
|
}
|
|
if cur, _ := entry["enable"].(bool); cur == false {
|
|
continue
|
|
}
|
|
entry["enable"] = false
|
|
entry["updated_at"] = now
|
|
clients[i] = entry
|
|
mutated = true
|
|
}
|
|
if !mutated {
|
|
return &snapshot, &ib, nil
|
|
}
|
|
settings["clients"] = clients
|
|
bs, marshalErr := json.MarshalIndent(settings, "", " ")
|
|
if marshalErr != nil {
|
|
return nil, nil, marshalErr
|
|
}
|
|
ib.Settings = string(bs)
|
|
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).
|
|
Update("settings", ib.Settings).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &snapshot, &ib, nil
|
|
}
|
|
|
|
func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error {
|
|
oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rt, err := s.runtimeFor(ib)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := rt.UpdateInbound(context.Background(), oldSnapshot, ib); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|