mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-09 21:00:58 +00:00
fix(node): stop the node sync from deleting clients it never meant to
A client that hit its quota or expiry was disabled, then destroyed on both panels a few seconds later. Five defects fed the same hard delete. ReconcileNode pushed buildRuntimeInboundForAPI, which strips disabled clients. Every other call site targets an in-memory Xray config, where dropping a user is harmless; a node target is a peer panel's DATABASE, so the node deleted the row, stopped reporting it, and the master mirrored that deletion back. Split the builder in two: buildInboundForNodePush injects fallbacks only, buildInboundForLocalRuntime adds the strip on top. The names now say which targets they are safe for. setRemoteTrafficLocked trusted a config_dirty the caller sampled before the snapshot round-trip. A client added inside that window commits on the same serialized writer and marks the node dirty, but the merge still treated the older snapshot as authoritative and deleted it. Re-read the flag inside the writer. In "selected" sync mode, FilterNodeSnapshot strips a deselected tag, but the sweep loaded every inbound with node_id set, so deselecting a tag read as "the node deleted it" and wiped an inbound the node still serves. Skip tags outside the node's managed set. A failed SyncInbound was logged and swallowed; on SQLite the transaction still commits, and the sweep then deleted the innocent clients whose links that failure had left unbuilt. Skip the sweep for such an inbound, and close the trigger: SyncInbound now stores the trimmed email it looks up by, and email validation rejects every unicode space rather than only U+0020. ClientService.Delete tombstones up front and deliberately keeps the record when an inbound fails, so the next attempt can retry the leftovers. The tombstone did not lift with it, so the next merge dropped the client from the synced settings and finished the deletion this path had refused. Add withdrawClientTombstones on every failure path, in BulkDelete too. Finally, make the sweep itself recoverable. "Ended the merge unattached" is true for a real remote deletion and equally true for a bad merge, so it now stamps sync_orphaned_at instead of deleting; any later merge that sees the client attached clears the mark, and a reaper removes only what stayed orphaned past the grace period. The traffic row survives that window too, or a reclaimed client would come back with its usage, quota and expiry reset. The mark is written by this sweep alone, so orphans from any other cause keep their existing manual-cleanup semantics.
This commit is contained in:
@@ -1078,7 +1078,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
payload := inbound
|
||||
pushable := true
|
||||
if inbound.Protocol == model.MTProto {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", bErr)
|
||||
@@ -1326,7 +1326,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
|
||||
runtimeInbound, err := s.buildInboundForLocalRuntime(db, inbound)
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: build runtime config failed:", err)
|
||||
return true, nil
|
||||
@@ -1511,7 +1511,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
payload := oldInbound
|
||||
pushable := true
|
||||
if inbound.Enable {
|
||||
if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
|
||||
if built, err2 := s.buildInboundForLocalRuntime(tx, oldInbound); err2 == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
@@ -1537,7 +1537,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
var runtimeInbound *model.Inbound
|
||||
if inbound.Enable {
|
||||
var err2 error
|
||||
runtimeInbound, err2 = s.buildRuntimeInboundForAPI(tx, oldInbound)
|
||||
runtimeInbound, err2 = s.buildInboundForLocalRuntime(tx, oldInbound)
|
||||
if err2 != nil {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
needRestart = true
|
||||
@@ -1608,81 +1608,95 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return inbound, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
// A node mirrors this payload into its own DB, so every client must survive:
|
||||
// filtering one out makes the node delete it, and the master then mirrors that.
|
||||
func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
if inbound == nil {
|
||||
return nil, fmt.Errorf("inbound is nil")
|
||||
}
|
||||
|
||||
runtimeInbound := *inbound
|
||||
built := *inbound
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mutated := false
|
||||
if clients, ok := settings["clients"].([]any); ok {
|
||||
var clientStats []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", inbound.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inboundCanHostFallbacks(inbound) {
|
||||
return &built, nil
|
||||
}
|
||||
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fallbacks) == 0 {
|
||||
return &built, nil
|
||||
}
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
|
||||
if mErr != nil {
|
||||
return nil, mErr
|
||||
}
|
||||
built.Settings = string(modifiedSettings)
|
||||
return &built, nil
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
|
||||
settings["clients"] = finalClients
|
||||
mutated = true
|
||||
// Strips disabled clients on top of the node payload. Safe only because the
|
||||
// target here is an in-memory Xray/mtg config, not another panel's database.
|
||||
func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
built, err := s.buildInboundForNodePush(tx, inbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if inboundCanHostFallbacks(inbound) {
|
||||
fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if fbErr != nil {
|
||||
return nil, fbErr
|
||||
}
|
||||
if len(fallbacks) > 0 {
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
mutated = true
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
return built, nil
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
return &runtimeInbound, nil
|
||||
var clientStats []xray.ClientTraffic
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", built.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
settings["clients"] = finalClients
|
||||
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeInbound := *built
|
||||
runtimeInbound.Settings = string(modifiedSettings)
|
||||
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user