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 53b05931d4
commit 73b2d64247
4 changed files with 67 additions and 9 deletions

View file

@ -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
}

View file

@ -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.

View file

@ -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)
}

View file

@ -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"`