mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
fix(multi-node): scope tag and email unique constraints per node
inbounds.tag was globally UNIQUE which prevents two remote nodes from having inbounds on the same port. client_traffics.email was globally UNIQUE which causes transaction rollback when different nodes have clients with the same email. Changed both to composite unique indexes scoped by their parent ((node_id, tag) and (inbound_id, email)). tagExists() is now node-aware so UI tag generation doesn't see remote node tags as conflicts. Adds migrateLegacyUniqueConstraints() to drop old constraints on startup.
This commit is contained in:
parent
53b05931d4
commit
73b2d64247
4 changed files with 67 additions and 9 deletions
|
|
@ -8,6 +8,7 @@ import (
|
|||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"path"
|
||||
"slices"
|
||||
"time"
|
||||
|
|
@ -47,6 +48,58 @@ func initModels() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
if err := migrateLegacyUniqueConstraints(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateLegacyUniqueConstraints migrates the globally unique constraints
|
||||
// that prevent multi-node setups from working correctly.
|
||||
//
|
||||
// 1. inbounds.tag: was globally UNIQUE, changed to per-node composite
|
||||
// unique index (node_id, tag). Two remote nodes can now each have an
|
||||
// inbound on the same port without hitting a UNIQUE constraint failure
|
||||
// during the traffic-sync mirror step.
|
||||
// 2. client_traffics.email: was globally UNIQUE, changed to per-inbound
|
||||
// composite unique index (inbound_id, email). Different nodes can have
|
||||
// clients with the same email address.
|
||||
func migrateLegacyUniqueConstraints() error {
|
||||
db := GetDB()
|
||||
|
||||
var createSQL string
|
||||
|
||||
// Migrate inbounds.tag global unique → per-node composite index
|
||||
if err := db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='inbounds'").Scan(&createSQL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(createSQL, " UNIQUE") {
|
||||
if err := db.Migrator().AlterColumn(&model.Inbound{}, "Tag"); err != nil {
|
||||
log.Printf("Error migrating inbounds.tag unique constraint: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Inbound{}); err != nil {
|
||||
log.Printf("Error re-creating indexes after tag migration: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate client_traffics.email global unique → per-inbound composite index
|
||||
createSQL = ""
|
||||
if err := db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='client_traffics'").Scan(&createSQL).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(createSQL, " UNIQUE") {
|
||||
if err := db.Migrator().AlterColumn(&xray.ClientTraffic{}, "Email"); err != nil {
|
||||
log.Printf("Error migrating client_traffics.email unique constraint: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&xray.ClientTraffic{}); err != nil {
|
||||
log.Printf("Error re-creating indexes after email migration: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,14 +64,14 @@ type Inbound struct {
|
|||
Protocol Protocol `json:"protocol" form:"protocol"`
|
||||
Settings string `json:"settings" form:"settings"`
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"uniqueIndex:idx_node_tag,priority:2"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
|
||||
// NodeID points at the remote panel (Node) where this inbound's xray
|
||||
// actually runs. NULL means the inbound runs on the local xray (the
|
||||
// pre-multi-node behaviour). Existing rows migrate to NULL with no
|
||||
// backfill.
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"uniqueIndex:idx_node_tag,priority:1;index"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func transportTagSuffix(b transportBits) string {
|
|||
// as a collision; pass 0 on add.
|
||||
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||
base := baseInboundTag(inbound.Listen, inbound.Port)
|
||||
exists, err := s.tagExists(base, ignoreId)
|
||||
exists, err := s.tagExists(base, ignoreId, inbound.NodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
|
|||
|
||||
suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
|
||||
candidate := base + "-" + suffix
|
||||
exists, err = s.tagExists(candidate, ignoreId)
|
||||
exists, err = s.tagExists(candidate, ignoreId, inbound.NodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
|
|||
// the user as an opaque sqlite error.
|
||||
for i := 2; i < 100; i++ {
|
||||
c := fmt.Sprintf("%s-%d", candidate, i)
|
||||
exists, err = s.tagExists(c, ignoreId)
|
||||
exists, err = s.tagExists(c, ignoreId, inbound.NodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -254,7 +254,7 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
|
|||
// own id on update so a row doesn't see its own current tag as taken.
|
||||
func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||
if inbound.Tag != "" {
|
||||
taken, err := s.tagExists(inbound.Tag, ignoreId)
|
||||
taken, err := s.tagExists(inbound.Tag, ignoreId, inbound.NodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -265,9 +265,14 @@ func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int)
|
|||
return s.generateInboundTag(inbound, ignoreId)
|
||||
}
|
||||
|
||||
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
||||
func (s *InboundService) tagExists(tag string, ignoreId int, nodeID *int) (bool, error) {
|
||||
db := database.GetDB()
|
||||
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
|
||||
if nodeID != nil {
|
||||
q = q.Where("node_id = ?", *nodeID)
|
||||
} else {
|
||||
q = q.Where("node_id IS NULL")
|
||||
}
|
||||
if ignoreId > 0 {
|
||||
q = q.Where("id != ?", ignoreId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ package xray
|
|||
// It tracks upload/download usage, expiry times, and online status for inbound clients.
|
||||
type ClientTraffic struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
InboundId int `json:"inboundId" form:"inboundId"`
|
||||
InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_ct_inbound_email,priority:1"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
Email string `json:"email" form:"email" gorm:"unique"`
|
||||
Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_ct_inbound_email,priority:2"`
|
||||
UUID string `json:"uuid" form:"uuid" gorm:"-"`
|
||||
SubId string `json:"subId" form:"subId" gorm:"-"`
|
||||
Up int64 `json:"up" form:"up"`
|
||||
|
|
|
|||
Loading…
Reference in a new issue