diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 9af1fac8f..4fdcb4726 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -719,6 +719,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet // then saves the inbound to the database and optionally adds it to the running Xray instance. // Returns the created inbound, whether Xray needs restart, and any error. func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { + inbound.Id = 0 // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil { diff --git a/internal/web/service/inbound_finalmask_reality_test.go b/internal/web/service/inbound_finalmask_reality_test.go index 3fb1086cc..6e9c7ab3d 100644 --- a/internal/web/service/inbound_finalmask_reality_test.go +++ b/internal/web/service/inbound_finalmask_reality_test.go @@ -91,6 +91,42 @@ func TestAddInbound_RejectsFinalMaskRealityCombo(t *testing.T) { } } +// AddInbound must always create a new row. The add controller binds the model's +// `id` form field and never clears it, so a client that reuses an existing id +// (e.g. duplicating an inbound fetched from /get) must not silently overwrite +// that stored row via GORM Save's upsert-on-primary-key behavior. +func TestAddInbound_IgnoresBoundIdAndCreatesNewRow(t *testing.T) { + setupConflictDB(t) + svc := &InboundService{} + + first := &model.Inbound{Tag: "in-45100-tcp", Enable: true, Listen: "0.0.0.0", Port: 45100, Protocol: model.VLESS, Settings: `{"clients":[]}`} + created, _, err := svc.AddInbound(first) + if err != nil { + t.Fatalf("AddInbound first: %v", err) + } + + second := &model.Inbound{Id: created.Id, Tag: "in-45101-tcp", Enable: true, Listen: "0.0.0.0", Port: 45101, Protocol: model.VLESS, Settings: `{"clients":[]}`} + if _, _, err := svc.AddInbound(second); err != nil { + t.Fatalf("AddInbound second: %v", err) + } + + var count int64 + if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil { + t.Fatalf("count: %v", err) + } + if count != 2 { + t.Fatalf("expected 2 inbound rows, got %d: a bound id overwrote the first row instead of creating a new one", count) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, created.Id).Error; err != nil { + t.Fatalf("reload first: %v", err) + } + if reloaded.Port != 45100 { + t.Fatalf("first inbound port = %d, want 45100 (the second add overwrote it)", reloaded.Port) + } +} + // end-to-end: same guard on the update path, on a row that was valid before // the edit — the rejected StreamSettings must not overwrite the stored row. func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {