From 6a483fa98794038e853b3e843c41f7866e0b7f5b Mon Sep 17 00:00:00 2001 From: pwnnex <276111476+pwnnex@users.noreply.github.com> Date: Wed, 6 May 2026 12:41:21 +0300 Subject: [PATCH] inbound: check transport in port conflict, allow tcp and udp on same port (#4169) the panel rejected configurations like vless reality on tcp/443 and hysteria2 on udp/443 even though those are independent sockets in linux. the old checkPortExist looked only at port + listen. inboundTransports now classifies each inbound by L4 transport: hysteria/hysteria2/wireguard are udp; streamSettings.network=kcp is udp; shadowsocks reads settings.network ("tcp"/"udp"/"tcp,udp"); mixed (socks/http) adds udp when settings.udp is true; everything else is tcp. checkPortConflict pulls every row on the same port and only flags a conflict when transport masks overlap. the listen- overlap rule (specific addr vs any-addr on the same port) is kept. inbounds.tag has a unique DB constraint and the controller derives tags from port ("inbound-443"). without disambiguation a second inbound on the same port would still hit a unique-constraint error. generateInboundTag keeps the historical "inbound-" shape when the base tag is free, so existing routing rules survive the upgrade unchanged, and appends "-tcp"/"-udp" only when the base is already taken. closes #4103. --- web/service/inbound.go | 53 ++--- web/service/port_conflict.go | 234 +++++++++++++++++++ web/service/port_conflict_test.go | 363 ++++++++++++++++++++++++++++++ 3 files changed, 614 insertions(+), 36 deletions(-) create mode 100644 web/service/port_conflict.go create mode 100644 web/service/port_conflict_test.go diff --git a/web/service/inbound.go b/web/service/inbound.go index ddb748f5..57bc4b98 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -104,36 +104,6 @@ func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbo return inbounds, nil } -func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (bool, error) { - db := database.GetDB() - if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" { - db = db.Model(model.Inbound{}).Where("port = ?", port) - } else { - db = db.Model(model.Inbound{}). - Where("port = ?", port). - Where( - db.Model(model.Inbound{}).Where( - "listen = ?", listen, - ).Or( - "listen = \"\"", - ).Or( - "listen = \"0.0.0.0\"", - ).Or( - "listen = \"::\"", - ).Or( - "listen = \"::0\"")) - } - if ignoreId > 0 { - db = db.Where("id != ?", ignoreId) - } - var count int64 - err := db.Count(&count).Error - if err != nil { - return false, err - } - return count > 0, nil -} - func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) { settings := map[string][]model.Client{} json.Unmarshal([]byte(inbound.Settings), &settings) @@ -221,7 +191,7 @@ func (s *InboundService) checkEmailExistForInbound(inbound *model.Inbound) (stri // 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) { - exist, err := s.checkPortExist(inbound.Listen, inbound.Port, 0) + exist, err := s.checkPortConflict(inbound, 0) if err != nil { return inbound, false, err } @@ -229,6 +199,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, common.NewError("Port already exists:", inbound.Port) } + // pick a tag that won't collide with an existing row. for the common + // case this is the same "inbound-" string the controller already + // set; only when this port already has another inbound on a different + // transport (now possible after the transport-aware port check) does + // this disambiguate with a -tcp/-udp suffix. see #4103. + inbound.Tag, err = s.generateInboundTag(inbound, 0) + if err != nil { + return inbound, false, err + } + existEmail, err := s.checkEmailExistForInbound(inbound) if err != nil { return inbound, false, err @@ -462,7 +442,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) { // It validates changes, updates the database, and syncs with the running Xray instance. // Returns the updated inbound, whether Xray needs restart, and any error. func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { - exist, err := s.checkPortExist(inbound.Listen, inbound.Port, inbound.Id) + exist, err := s.checkPortConflict(inbound, inbound.Id) if err != nil { return inbound, false, err } @@ -565,10 +545,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, oldInbound.Settings = inbound.Settings oldInbound.StreamSettings = inbound.StreamSettings oldInbound.Sniffing = inbound.Sniffing - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - oldInbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port) - } else { - oldInbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port) + // regenerate tag with collision-aware logic. for this row we pass + // inbound.Id as ignoreId so it doesn't see its own old tag in the db. + oldInbound.Tag, err = s.generateInboundTag(inbound, inbound.Id) + if err != nil { + return inbound, false, err } needRestart := false diff --git a/web/service/port_conflict.go b/web/service/port_conflict.go new file mode 100644 index 00000000..12336515 --- /dev/null +++ b/web/service/port_conflict.go @@ -0,0 +1,234 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/util/common" +) + +// transportBits is a bitmask of L4 transports an inbound listens on. +// 0.0.0.0:443/tcp and 0.0.0.0:443/udp are independent sockets in linux, +// so the conflict check needs more than just the port number. +type transportBits uint8 + +const ( + transportTCP transportBits = 1 << iota + transportUDP +) + +// conflicts is true when the two masks share any L4 transport. +func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 } + +// inboundTransports returns the L4 transports the given inbound listens on. +// always returns at least one bit (falls back to tcp on parse errors), so +// the validator never gets looser than the old port-only check. +// +// the rules: +// - hysteria, hysteria2, wireguard: udp regardless of streamSettings +// - streamSettings.network=kcp: udp +// - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp") +// - mixed (socks/http combo): tcp + udp when settings.udp is true +// - everything else: tcp +func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits { + // protocols that ignore streamSettings entirely. + switch protocol { + case model.Hysteria, model.Hysteria2, model.WireGuard: + return transportUDP + } + + var bits transportBits + + // peek at streamSettings.network to spot udp transports like kcp. + // parse errors are non-fatal: missing or weird streamSettings just + // keeps the default tcp bit below. + network := "" + if streamSettings != "" { + var ss map[string]any + if json.Unmarshal([]byte(streamSettings), &ss) == nil { + if n, _ := ss["network"].(string); n != "" { + network = n + } + } + } + if network == "kcp" { + bits |= transportUDP + } else { + bits |= transportTCP + } + + // some protocols also listen on udp on the same port via their own + // settings json. parse and merge. + if settings != "" { + var st map[string]any + if json.Unmarshal([]byte(settings), &st) == nil { + switch protocol { + case model.Shadowsocks: + // shadowsocks settings.network controls both tcp and udp, + // independently of streamSettings. the field takes "tcp", + // "udp", or "tcp,udp". if it's set, it wins outright. + if n, ok := st["network"].(string); ok && n != "" { + bits = 0 + for _, part := range strings.Split(n, ",") { + switch strings.TrimSpace(part) { + case "tcp": + bits |= transportTCP + case "udp": + bits |= transportUDP + } + } + } + case model.Mixed: + // socks/http "mixed" inbound: settings.udp=true means it + // also relays udp on the same port (socks5 udp associate). + if udpOn, _ := st["udp"].(bool); udpOn { + bits |= transportUDP + } + } + } + } + + // safety net: never return zero, even if every parse failed. + if bits == 0 { + bits = transportTCP + } + return bits +} + +// listenOverlaps reports whether two listen addresses can collide on the +// same port. preserves the rule from the original checkPortExist: +// any-address (empty / 0.0.0.0 / :: / ::0) overlaps with everything, +// otherwise only identical specific addresses overlap. +func listenOverlaps(a, b string) bool { + if isAnyListen(a) || isAnyListen(b) { + return true + } + return a == b +} + +func isAnyListen(s string) bool { + return s == "" || s == "0.0.0.0" || s == "::" || s == "::0" +} + +// checkPortConflict reports whether adding/updating an inbound on +// (listen, port) would clash with an existing inbound. unlike the old +// port-only check, this one understands that tcp/443 and udp/443 are +// independent sockets in linux and may coexist on the same address. +// +// the listen-overlap rule (specific addr conflicts with any-addr on the +// same port, both directions) is preserved from the previous check. +func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) { + db := database.GetDB() + + // pull every candidate on this port; we filter by listen-overlap and + // transport in go to keep the sql plain. the port column is indexed + // in practice by the existing port check, and the candidate set is + // tiny (one per coexisting socket family at most). + var candidates []*model.Inbound + q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port) + if ignoreId > 0 { + q = q.Where("id != ?", ignoreId) + } + if err := q.Find(&candidates).Error; err != nil { + return false, err + } + + newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings) + for _, c := range candidates { + if !listenOverlaps(c.Listen, inbound.Listen) { + continue + } + if inboundTransports(c.Protocol, c.StreamSettings, c.Settings).conflicts(newBits) { + return true, nil + } + } + return false, nil +} + +// baseInboundTag is the historical "inbound-" / "inbound-:" +// shape. kept exactly so existing routing rules that reference these tags +// keep working after the upgrade. +func baseInboundTag(listen string, port int) string { + if isAnyListen(listen) { + return fmt.Sprintf("inbound-%v", port) + } + return fmt.Sprintf("inbound-%v:%v", listen, port) +} + +// transportTagSuffix turns a transport mask into a short, stable string +// for tag disambiguation. only used when the base "inbound-" is +// already taken on a coexisting transport (e.g. tcp inbound already lives +// on 443 and we're now adding a udp one). +func transportTagSuffix(b transportBits) string { + switch b { + case transportTCP: + return "tcp" + case transportUDP: + return "udp" + case transportTCP | transportUDP: + return "mixed" + } + return "any" +} + +// generateInboundTag picks a tag for the inbound that doesn't collide with +// any existing row. for the common single-inbound-per-port case the tag +// stays exactly as before ("inbound-443"), so user routing rules don't +// silently change shape on upgrade. only when a same-port neighbour +// already owns the base tag (now possible because tcp/443 and udp/443 can +// coexist after the transport-aware port check) does this append a +// transport suffix like "inbound-443-udp". +// +// ignoreId is the inbound's own id during update so it doesn't see itself +// 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) + if err != nil { + return "", err + } + if !exists { + return base, nil + } + + suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)) + candidate := base + "-" + suffix + exists, err = s.tagExists(candidate, ignoreId) + if err != nil { + return "", err + } + if !exists { + return candidate, nil + } + + // the transport-aware port check should have already blocked this + // path, but guard anyway so a unique-constraint failure doesn't reach + // 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) + if err != nil { + return "", err + } + if !exists { + return c, nil + } + } + return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port) +} + +func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) { + db := database.GetDB() + q := db.Model(model.Inbound{}).Where("tag = ?", tag) + if ignoreId > 0 { + q = q.Where("id != ?", ignoreId) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/web/service/port_conflict_test.go b/web/service/port_conflict_test.go new file mode 100644 index 00000000..d9de3457 --- /dev/null +++ b/web/service/port_conflict_test.go @@ -0,0 +1,363 @@ +package service + +import ( + "path/filepath" + "sync" + "testing" + + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + xuilogger "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/op/go-logging" +) + +// the panel logger is a process-wide singleton. init it once per test +// binary so a stray warning from gorm doesn't blow up on a nil logger. +var portConflictLoggerOnce sync.Once + +// setupConflictDB wires a temp sqlite db so checkPortConflict can read +// real candidates. closes the db before t.TempDir cleans up so windows +// doesn't refuse to remove the file. +func setupConflictDB(t *testing.T) { + t.Helper() + portConflictLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) }) + + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { + if err := database.CloseDB(); err != nil { + t.Logf("CloseDB warning: %v", err) + } + }) +} + +func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) { + t.Helper() + in := &model.Inbound{ + Tag: tag, + Enable: true, + Listen: listen, + Port: port, + Protocol: protocol, + StreamSettings: streamSettings, + Settings: settings, + } + if err := database.GetDB().Create(in).Error; err != nil { + t.Fatalf("seed inbound %s: %v", tag, err) + } +} + +func TestInboundTransports(t *testing.T) { + cases := []struct { + name string + protocol model.Protocol + streamSettings string + settings string + want transportBits + }{ + {"vless default tcp", model.VLESS, `{"network":"tcp"}`, ``, transportTCP}, + {"vless ws (still tcp)", model.VLESS, `{"network":"ws"}`, ``, transportTCP}, + {"vless kcp is udp", model.VLESS, `{"network":"kcp"}`, ``, transportUDP}, + {"vless empty stream defaults to tcp", model.VLESS, ``, ``, transportTCP}, + {"vless garbage stream stays tcp", model.VLESS, `not json`, ``, transportTCP}, + + {"vmess default tcp", model.VMESS, `{"network":"tcp"}`, ``, transportTCP}, + {"trojan grpc is tcp", model.Trojan, `{"network":"grpc"}`, ``, transportTCP}, + + {"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP}, + {"hysteria2 forced udp", model.Hysteria2, ``, ``, transportUDP}, + {"wireguard forced udp", model.WireGuard, ``, ``, transportUDP}, + + {"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP}, + {"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP}, + {"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP}, + {"shadowsocks empty network falls back to streamSettings", model.Shadowsocks, `{"network":"tcp"}`, `{}`, transportTCP}, + + {"mixed udp on", model.Mixed, `{"network":"tcp"}`, `{"udp":true}`, transportTCP | transportUDP}, + {"mixed udp off", model.Mixed, `{"network":"tcp"}`, `{"udp":false}`, transportTCP}, + {"mixed udp missing", model.Mixed, `{"network":"tcp"}`, `{}`, transportTCP}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := inboundTransports(c.protocol, c.streamSettings, c.settings) + if got != c.want { + t.Fatalf("got bits %#b, want %#b", got, c.want) + } + }) + } +} + +func TestListenOverlaps(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"", "", true}, + {"0.0.0.0", "", true}, + {"0.0.0.0", "1.2.3.4", true}, + {"::", "1.2.3.4", true}, + {"::0", "fe80::1", true}, + {"1.2.3.4", "1.2.3.4", true}, + {"1.2.3.4", "5.6.7.8", false}, + {"1.2.3.4", "::1", false}, + } + for _, c := range cases { + if got := listenOverlaps(c.a, c.b); got != c.want { + t.Errorf("listenOverlaps(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +// the actual case from #4103: tcp/443 vless reality and udp/443 +// hysteria2 must be allowed to coexist on the same port. +func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "vless-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + svc := &InboundService{} + hyst2 := &model.Inbound{ + Tag: "hyst2-443-udp", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.Hysteria2, + } + exist, err := svc.checkPortConflict(hyst2, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if exist { + t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist") + } +} + +// two tcp inbounds on the same port still conflict. +func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "vless-443-a", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + svc := &InboundService{} + other := &model.Inbound{ + Tag: "vless-443-b", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.Trojan, + StreamSettings: `{"network":"ws"}`, + } + exist, err := svc.checkPortConflict(other, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if !exist { + t.Fatalf("two tcp inbounds on the same port must still conflict") + } +} + +// two udp inbounds (e.g. hysteria2 vs wireguard) on the same port also +// conflict, since they fight for the same socket. +func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria2, ``, ``) + + svc := &InboundService{} + wg := &model.Inbound{ + Tag: "wg-443", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.WireGuard, + } + exist, err := svc.checkPortConflict(wg, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if !exist { + t.Fatalf("two udp inbounds on the same port must conflict") + } +} + +// shadowsocks listening on tcp+udp eats the whole port for both +// transports, so neither a tcp nor a udp neighbour is allowed. +func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "ss-443-dual", "0.0.0.0", 443, model.Shadowsocks, ``, `{"network":"tcp,udp"}`) + + svc := &InboundService{} + + tcpClash := &model.Inbound{ + Tag: "vless-443", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + } + if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || !exist { + t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err) + } + + udpClash := &model.Inbound{ + Tag: "hyst2-443", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.Hysteria2, + } + if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist { + t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err) + } +} + +// different ports never conflict regardless of transport. +func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + svc := &InboundService{} + other := &model.Inbound{ + Tag: "vless-444", + Listen: "0.0.0.0", + Port: 444, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + } + if exist, err := svc.checkPortConflict(other, 0); err != nil || exist { + t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err) + } +} + +// specific listen addresses on the same port don't clash with each other, +// but do clash with any-address on the same port (preserved from the old +// check). +func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "vless-1.2.3.4", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + svc := &InboundService{} + + // different specific address, same port + transport: no conflict. + other := &model.Inbound{ + Tag: "vless-5.6.7.8", + Listen: "5.6.7.8", + Port: 443, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + } + if exist, err := svc.checkPortConflict(other, 0); err != nil || exist { + t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err) + } + + // any-address vs specific on same transport: conflict (any-addr wins). + anyAddr := &model.Inbound{ + Tag: "vless-any", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp"}`, + } + if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || !exist { + t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err) + } +} + +// when the base "inbound-" tag is already taken on a coexisting +// transport, generateInboundTag must disambiguate with a transport +// suffix so the unique-tag DB constraint stays satisfied. +func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) { + setupConflictDB(t) + // existing tcp inbound owns "inbound-443". + seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + svc := &InboundService{} + udp := &model.Inbound{ + Listen: "0.0.0.0", + Port: 443, + Protocol: model.Hysteria2, + } + got, err := svc.generateInboundTag(udp, 0) + if err != nil { + t.Fatalf("generateInboundTag: %v", err) + } + if got != "inbound-443-udp" { + t.Fatalf("expected disambiguated tag inbound-443-udp, got %q", got) + } +} + +// when the port is free, the historical "inbound-" shape is kept +// so existing routing rules don't change shape on upgrade. +func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) { + setupConflictDB(t) + + svc := &InboundService{} + in := &model.Inbound{ + Listen: "0.0.0.0", + Port: 8443, + Protocol: model.VLESS, + } + got, err := svc.generateInboundTag(in, 0) + if err != nil { + t.Fatalf("generateInboundTag: %v", err) + } + if got != "inbound-8443" { + t.Fatalf("expected inbound-8443, got %q", got) + } +} + +// updating an inbound on its own port must not flag its own tag as +// taken, that's what ignoreId is for. +func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + var existing model.Inbound + if err := database.GetDB().Where("tag = ?", "inbound-443").First(&existing).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + + svc := &InboundService{} + got, err := svc.generateInboundTag(&existing, existing.Id) + if err != nil { + t.Fatalf("generateInboundTag: %v", err) + } + if got != "inbound-443" { + t.Fatalf("self-update must keep base tag, got %q", got) + } +} + +// specific listen address gets the listen-prefixed shape and same +// disambiguation rules. +func TestGenerateInboundTag_SpecificListenSameDisambiguation(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "inbound-1.2.3.4:443", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + svc := &InboundService{} + udp := &model.Inbound{ + Listen: "1.2.3.4", + Port: 443, + Protocol: model.Hysteria2, + } + got, err := svc.generateInboundTag(udp, 0) + if err != nil { + t.Fatalf("generateInboundTag: %v", err) + } + if got != "inbound-1.2.3.4:443-udp" { + t.Fatalf("expected inbound-1.2.3.4:443-udp, got %q", got) + } +} + +// updating an inbound must not see itself as a conflict, that's what +// ignoreId is for. +func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`) + + var existing model.Inbound + if err := database.GetDB().Where("tag = ?", "vless-443").First(&existing).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + + svc := &InboundService{} + if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist { + t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err) + } +}