From 73b2d642473972e7552654d633dba21e0e9da7d3 Mon Sep 17 00:00:00 2001 From: abdulrahman Date: Tue, 12 May 2026 10:54:49 +0300 Subject: [PATCH] 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. --- database/db.go | 53 ++++++++++++++++++++++++++++++++++++ database/model/model.go | 4 +-- web/service/port_conflict.go | 15 ++++++---- xray/client_traffic.go | 4 +-- 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/database/db.go b/database/db.go index 64d3765d..3fd45029 100644 --- a/database/db.go +++ b/database/db.go @@ -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 } diff --git a/database/model/model.go b/database/model/model.go index 56a76b6e..7c43ae42 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -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. diff --git a/web/service/port_conflict.go b/web/service/port_conflict.go index a2dd2183..cc746d2b 100644 --- a/web/service/port_conflict.go +++ b/web/service/port_conflict.go @@ -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) } diff --git a/xray/client_traffic.go b/xray/client_traffic.go index fcb2585e..accd75c1 100644 --- a/xray/client_traffic.go +++ b/xray/client_traffic.go @@ -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"`