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:
abdulrahman 2026-05-12 10:54:49 +03:00
parent 355bb4c9c0
commit 686ca1f637
4 changed files with 67 additions and 9 deletions

View file

@ -8,6 +8,7 @@ import (
"io" "io"
"log" "log"
"os" "os"
"strings"
"path" "path"
"slices" "slices"
"time" "time"
@ -47,6 +48,58 @@ func initModels() error {
return err 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 return nil
} }

View file

@ -64,14 +64,14 @@ type Inbound struct {
Protocol Protocol `json:"protocol" form:"protocol"` Protocol Protocol `json:"protocol" form:"protocol"`
Settings string `json:"settings" form:"settings"` Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"` 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"` Sniffing string `json:"sniffing" form:"sniffing"`
// NodeID points at the remote panel (Node) where this inbound's xray // NodeID points at the remote panel (Node) where this inbound's xray
// actually runs. NULL means the inbound runs on the local xray (the // actually runs. NULL means the inbound runs on the local xray (the
// pre-multi-node behaviour). Existing rows migrate to NULL with no // pre-multi-node behaviour). Existing rows migrate to NULL with no
// backfill. // 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. // OutboundTraffics tracks traffic statistics for Xray outbound connections.

View file

@ -205,7 +205,7 @@ func transportTagSuffix(b transportBits) string {
// as a collision; pass 0 on add. // as a collision; pass 0 on add.
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) { func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
base := baseInboundTag(inbound.Listen, inbound.Port) base := baseInboundTag(inbound.Listen, inbound.Port)
exists, err := s.tagExists(base, ignoreId) exists, err := s.tagExists(base, ignoreId, inbound.NodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -215,7 +215,7 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)) suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
candidate := base + "-" + suffix candidate := base + "-" + suffix
exists, err = s.tagExists(candidate, ignoreId) exists, err = s.tagExists(candidate, ignoreId, inbound.NodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -228,7 +228,7 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
// the user as an opaque sqlite error. // the user as an opaque sqlite error.
for i := 2; i < 100; i++ { for i := 2; i < 100; i++ {
c := fmt.Sprintf("%s-%d", candidate, 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 { if err != nil {
return "", err 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. // 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) { func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
if inbound.Tag != "" { if inbound.Tag != "" {
taken, err := s.tagExists(inbound.Tag, ignoreId) taken, err := s.tagExists(inbound.Tag, ignoreId, inbound.NodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -265,9 +265,14 @@ func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int)
return s.generateInboundTag(inbound, ignoreId) 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() db := database.GetDB()
q := db.Model(model.Inbound{}).Where("tag = ?", tag) 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 { if ignoreId > 0 {
q = q.Where("id != ?", ignoreId) q = q.Where("id != ?", ignoreId)
} }

View file

@ -4,9 +4,9 @@ package xray
// It tracks upload/download usage, expiry times, and online status for inbound clients. // It tracks upload/download usage, expiry times, and online status for inbound clients.
type ClientTraffic struct { type ClientTraffic struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` 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"` 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:"-"` UUID string `json:"uuid" form:"uuid" gorm:"-"`
SubId string `json:"subId" form:"subId" gorm:"-"` SubId string `json:"subId" form:"subId" gorm:"-"`
Up int64 `json:"up" form:"up"` Up int64 `json:"up" form:"up"`