From 091dbc0c6e27e065a12323df0c6e15df8bc27272 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 14 Jul 2026 23:42:44 +0200 Subject: [PATCH] fix(inbound): always create in AddInbound instead of overwriting a row whose id was posted The add controller binds the inbound model's id form field and never clears it, and AddInbound persisted with GORM Save, which updates in place when the primary key is non-zero. A client that reused an existing id (for instance by duplicating an inbound fetched from /get and changing the port) silently overwrote that stored row instead of creating a new inbound. Zero the id at the top of AddInbound, matching how it already zeroes the client-stat ids. --- internal/web/service/inbound.go | 1 + .../service/inbound_finalmask_reality_test.go | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) 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) {