mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
fix(inbounds): close the port check-and-claim race on the serial writer (#6225)
* fix(inbounds): close the port check-and-claim race on the serial writer AddInbound reads the port conflict outside its transaction and then commits in a bare db.Transaction, so two overlapping creates both pass the read and both insert. UpdateInbound already runs on the single traffic writer, and so does the node snapshot path; AddInbound is the one inbound writer left out. Move it onto runSerializedTx and evaluate the conflict inside the transaction, in both AddInbound and UpdateInbound. The check and the claim then commit together on one goroutine, which closes the window on SQLite (immediate write lock) and PostgreSQL alike without new schema, locks or configuration. The wildcard/specific pair is the case worth naming: those are two distinct rows, so no unique index can reject them — only the semantic check can, and only if nothing can interleave between it and the insert. * fix(inbounds): restore the port check UpdateInbound lost The previous commit deleted UpdateInbound's pre-flight conflict check and never added the in-transaction one, so editing an inbound onto an occupied port was accepted outright. No test covered that path, so CI stayed green. Evaluate the conflict inside the transaction, as AddInbound already does, and add the regression test that fails without it. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
This commit is contained in:
@@ -930,18 +930,11 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
conflict, err := s.checkPortConflict(inbound, 0)
|
tag, err := s.resolveInboundTag(inbound, 0)
|
||||||
if err != nil {
|
|
||||||
return inbound, false, err
|
|
||||||
}
|
|
||||||
if conflict != nil {
|
|
||||||
return inbound, false, common.NewError(conflict.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound.Tag, err = s.resolveInboundTag(inbound, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
|
inbound.Tag = tag
|
||||||
|
|
||||||
clients, err := s.GetClients(inbound)
|
clients, err := s.GetClients(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1027,10 +1020,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
var postCommitApply func()
|
var postCommitApply func()
|
||||||
err = db.Transaction(func(tx *gorm.DB) error {
|
err = runSerializedTx(func(tx *gorm.DB) error {
|
||||||
|
conflict, cErr := checkPortConflictTx(tx, inbound, 0)
|
||||||
|
if cErr != nil {
|
||||||
|
return cErr
|
||||||
|
}
|
||||||
|
if conflict != nil {
|
||||||
|
return common.NewError(conflict.String())
|
||||||
|
}
|
||||||
markDirty := false
|
markDirty := false
|
||||||
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
|
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1416,14 +1415,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
|
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
|
||||||
inbound.NodeID = oldInbound.NodeID
|
inbound.NodeID = oldInbound.NodeID
|
||||||
|
|
||||||
conflict, err := s.checkPortConflict(inbound, inbound.Id)
|
|
||||||
if err != nil {
|
|
||||||
return inbound, false, err
|
|
||||||
}
|
|
||||||
if conflict != nil {
|
|
||||||
return inbound, false, common.NewError(conflict.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture the pre-edit protocol and routing state before oldInbound is
|
// Capture the pre-edit protocol and routing state before oldInbound is
|
||||||
// overwritten with the new values further down, then ensure a routed
|
// overwritten with the new values further down, then ensure a routed
|
||||||
// inbound keeps a stable egress port (reusing the one already stored).
|
// inbound keeps a stable egress port (reusing the one already stored).
|
||||||
@@ -1441,6 +1432,13 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
var postCommitApply func()
|
var postCommitApply func()
|
||||||
|
|
||||||
txErr := runSerializedTx(func(tx *gorm.DB) error {
|
txErr := runSerializedTx(func(tx *gorm.DB) error {
|
||||||
|
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
|
||||||
|
if cErr != nil {
|
||||||
|
return cErr
|
||||||
|
}
|
||||||
|
if conflict != nil {
|
||||||
|
return common.NewError(conflict.String())
|
||||||
|
}
|
||||||
if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil {
|
if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A wildcard listener and a specific one on the same port overlap, but they are
|
||||||
|
// two different rows: only the in-transaction check can reject the pair, and it
|
||||||
|
// can only do so if the check and the insert cannot interleave.
|
||||||
|
func TestAddInboundConcurrentOverlappingListenersSingleWinner(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
|
||||||
|
const rounds = 25
|
||||||
|
for round := range rounds {
|
||||||
|
port := 24000 + round
|
||||||
|
claims := []*model.Inbound{
|
||||||
|
{
|
||||||
|
Tag: fmt.Sprintf("race-%d-wildcard", round), Listen: "",
|
||||||
|
Port: port, Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Tag: fmt.Sprintf("race-%d-specific", round), Listen: "127.0.0.1",
|
||||||
|
Port: port, Protocol: model.Trojan,
|
||||||
|
StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
errs := make(chan error, len(claims))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, claim := range claims {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(inbound *model.Inbound) {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
_, _, err := (&InboundService{}).AddInbound(inbound)
|
||||||
|
errs <- err
|
||||||
|
}(claim)
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
committed := 0
|
||||||
|
rejections := make([]string, 0, len(claims))
|
||||||
|
for err := range errs {
|
||||||
|
if err == nil {
|
||||||
|
committed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rejections = append(rejections, err.Error())
|
||||||
|
}
|
||||||
|
if committed != 1 {
|
||||||
|
t.Fatalf("round %d port %d: concurrent AddInbound committed=%d, want exactly 1 (rejections: %v)",
|
||||||
|
round, port, committed, rejections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing an inbound onto a port another one already holds must be rejected —
|
||||||
|
// the check moved inside the transaction, and nothing else guards this path.
|
||||||
|
func TestUpdateInboundRejectsPortTakenByAnother(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
first := &model.Inbound{
|
||||||
|
Tag: "update-holder", Listen: "", Port: 25101, Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
|
||||||
|
}
|
||||||
|
if _, _, err := svc.AddInbound(first); err != nil {
|
||||||
|
t.Fatalf("seed holder: %v", err)
|
||||||
|
}
|
||||||
|
second := &model.Inbound{
|
||||||
|
Tag: "update-mover", Listen: "", Port: 25102, Protocol: model.VLESS,
|
||||||
|
StreamSettings: `{"network":"tcp"}`, Settings: `{"clients":[]}`,
|
||||||
|
}
|
||||||
|
if _, _, err := svc.AddInbound(second); err != nil {
|
||||||
|
t.Fatalf("seed mover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second.Port = first.Port
|
||||||
|
if _, _, err := svc.UpdateInbound(second); err == nil {
|
||||||
|
t.Fatal("moving an inbound onto a port already in use was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored model.Inbound
|
||||||
|
if err := database.GetDB().First(&stored, second.Id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Port != 25102 {
|
||||||
|
t.Fatalf("rejected update still changed the stored port to %d", stored.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type transportBits uint8
|
type transportBits uint8
|
||||||
@@ -158,7 +160,13 @@ func reservedAPIPort() int {
|
|||||||
return defaultXrayAPIPort
|
return defaultXrayAPIPort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkPortConflict reads outside any transaction; callers that must not race a
|
||||||
|
// concurrent create use checkPortConflictTx inside their own transaction.
|
||||||
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
|
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
|
||||||
|
return checkPortConflictTx(database.GetDB(), inbound, ignoreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
|
||||||
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
||||||
|
|
||||||
// The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row,
|
// The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row,
|
||||||
@@ -175,8 +183,6 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
var candidates []*model.Inbound
|
var candidates []*model.Inbound
|
||||||
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
|
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
|
||||||
if ignoreId > 0 {
|
if ignoreId > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user