mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +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"
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue