mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
refactor(inbound-tag): node-prefixed + transport-suffixed canonical shape
Tag scheme moves to "[n<nodeID>-]inbound-[<listen>:]<port>-<transport>"
so two long-standing collision classes go away on the create path:
- tcp/443 and udp/443 on the same listener (independent sockets)
- same listen+port living on the central panel and on a remote node
Examples:
local TCP 443 → inbound-443-tcp
local UDP 443 → inbound-443-udp
node 1 TCP 443 → n1-inbound-443-tcp
Refactor:
- composeInboundTag is the single source of truth, called from
generateInboundTag. Transport segment is now always present
(used to appear only on collision); n<id>- prefix is added when
Inbound.NodeID != nil.
- addInbound / importInbound drop their inline "inbound-<port>"
fallback; an empty Tag now flows through resolveInboundTag, which
keeps caller-supplied tags verbatim when free and otherwise
delegates to generateInboundTag.
- setRemoteTrafficLocked indexes tagToCentral under both the stored
tag and the prefix-stripped form, so a node sending its bare tag
still resolves to a row we may have rewritten at materialization.
The create branch now picks between snap.Tag and the n<id>-
prefixed form before falling back to the warn-once skip.
- Tests updated for the always-on transport suffix, and two new
cases cover the node-prefix behaviour.
Existing inbounds keep their tags — only newly generated tags adopt
the new shape, so user routing rules pointing at "inbound-443" still
match the row they always did until the row is recreated.
This commit is contained in:
parent
d347605233
commit
7ade9d9a1f
4 changed files with 125 additions and 75 deletions
|
|
@ -2,7 +2,6 @@ package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -145,17 +144,6 @@ func (a *InboundController) addInbound(c *gin.Context) {
|
||||||
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
||||||
inbound.NodeID = nil
|
inbound.NodeID = nil
|
||||||
}
|
}
|
||||||
// When the central panel deploys an inbound to a remote node, it sends
|
|
||||||
// the Tag pre-computed (so both DBs agree on the identifier). Local
|
|
||||||
// UI submits don't include a Tag — we compute one from listen+port
|
|
||||||
// using the original collision-avoiding scheme.
|
|
||||||
if inbound.Tag == "" {
|
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
||||||
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
|
|
||||||
} else {
|
|
||||||
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -338,13 +326,6 @@ func (a *InboundController) importInbound(c *gin.Context) {
|
||||||
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
||||||
inbound.NodeID = nil
|
inbound.NodeID = nil
|
||||||
}
|
}
|
||||||
if inbound.Tag == "" {
|
|
||||||
if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" {
|
|
||||||
inbound.Tag = fmt.Sprintf("inbound-%v", inbound.Port)
|
|
||||||
} else {
|
|
||||||
inbound.Tag = fmt.Sprintf("inbound-%v:%v", inbound.Listen, inbound.Port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for index := range inbound.ClientStats {
|
for index := range inbound.ClientStats {
|
||||||
inbound.ClientStats[index].Id = 0
|
inbound.ClientStats[index].Id = 0
|
||||||
|
|
|
||||||
|
|
@ -1258,9 +1258,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
Find(¢ral).Error; err != nil {
|
Find(¢ral).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
tagToCentral := make(map[string]*model.Inbound, len(central))
|
// Index under both stored tag and the prefix-stripped form so a snap's
|
||||||
|
// bare tag resolves whether or not we rewrote it with n<id>- at create.
|
||||||
|
tagToCentral := make(map[string]*model.Inbound, len(central)*2)
|
||||||
|
prefix := nodeTagPrefix(&nodeID)
|
||||||
for i := range central {
|
for i := range central {
|
||||||
tagToCentral[central[i].Tag] = ¢ral[i]
|
tagToCentral[central[i].Tag] = ¢ral[i]
|
||||||
|
if prefix != "" && strings.HasPrefix(central[i].Tag, prefix) {
|
||||||
|
tagToCentral[strings.TrimPrefix(central[i].Tag, prefix)] = ¢ral[i]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var centralClientStats []xray.ClientTraffic
|
var centralClientStats []xray.ClientTraffic
|
||||||
|
|
@ -1317,28 +1323,44 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
|
|
||||||
c, ok := tagToCentral[snapIb.Tag]
|
c, ok := tagToCentral[snapIb.Tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
var owner model.Inbound
|
// Try snap.Tag first; on collision fall back to the n<id>-
|
||||||
if err := tx.Where("tag = ?", snapIb.Tag).First(&owner).Error; err == nil {
|
// prefixed form so local+node can both own the same port.
|
||||||
|
pickFreeTag := func() (string, error) {
|
||||||
|
candidates := []string{snapIb.Tag}
|
||||||
|
if prefix != "" && !strings.HasPrefix(snapIb.Tag, prefix) {
|
||||||
|
candidates = append(candidates, prefix+snapIb.Tag)
|
||||||
|
}
|
||||||
|
for _, t := range candidates {
|
||||||
|
var owner model.Inbound
|
||||||
|
err := tx.Where("tag = ?", t).First(&owner).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
chosenTag, err := pickFreeTag()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("setRemoteTraffic: check tag %q failed: %v", snapIb.Tag, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chosenTag == "" {
|
||||||
key := fmt.Sprintf("%d:%s", nodeID, snapIb.Tag)
|
key := fmt.Sprintf("%d:%s", nodeID, snapIb.Tag)
|
||||||
if _, seen := reportedRemoteTagConflict.LoadOrStore(key, struct{}{}); !seen {
|
if _, seen := reportedRemoteTagConflict.LoadOrStore(key, struct{}{}); !seen {
|
||||||
ownerLabel := "the local panel"
|
|
||||||
if owner.NodeID != nil {
|
|
||||||
ownerLabel = fmt.Sprintf("node #%d", *owner.NodeID)
|
|
||||||
}
|
|
||||||
logger.Warningf(
|
logger.Warningf(
|
||||||
"setRemoteTraffic: tag %q from node %d collides with inbound owned by %s — skipping (rename one side to remove the duplicate)",
|
"setRemoteTraffic: tag %q from node %d collides with an existing inbound even after the n%d- prefix — skipping (rename one side to remove the duplicate)",
|
||||||
snapIb.Tag, nodeID, ownerLabel,
|
snapIb.Tag, nodeID, nodeID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
logger.Warningf("setRemoteTraffic: check tag %q failed: %v", snapIb.Tag, err)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
newIb := model.Inbound{
|
newIb := model.Inbound{
|
||||||
UserId: defaultUserId,
|
UserId: defaultUserId,
|
||||||
NodeID: &nodeID,
|
NodeID: &nodeID,
|
||||||
Tag: snapIb.Tag,
|
Tag: chosenTag,
|
||||||
Listen: snapIb.Listen,
|
Listen: snapIb.Listen,
|
||||||
Port: snapIb.Port,
|
Port: snapIb.Port,
|
||||||
Protocol: snapIb.Protocol,
|
Protocol: snapIb.Protocol,
|
||||||
|
|
@ -1358,6 +1380,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tagToCentral[snapIb.Tag] = &newIb
|
tagToCentral[snapIb.Tag] = &newIb
|
||||||
|
if newIb.Tag != snapIb.Tag {
|
||||||
|
tagToCentral[newIb.Tag] = &newIb
|
||||||
|
}
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,9 +223,9 @@ func sameNode(a, b *int) bool {
|
||||||
return *a == *b
|
return *a == *b
|
||||||
}
|
}
|
||||||
|
|
||||||
// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
|
// baseInboundTag is the legacy "inbound-<port>" / "inbound-<listen>:<port>"
|
||||||
// shape. kept exactly so existing routing rules that reference these tags
|
// shape still emitted by node-side xray imports that pre-date the
|
||||||
// keep working after the upgrade.
|
// transport-aware naming; kept as a probe shape in setRemoteTrafficLocked.
|
||||||
func baseInboundTag(listen string, port int) string {
|
func baseInboundTag(listen string, port int) string {
|
||||||
if isAnyListen(listen) {
|
if isAnyListen(listen) {
|
||||||
return fmt.Sprintf("inbound-%v", port)
|
return fmt.Sprintf("inbound-%v", port)
|
||||||
|
|
@ -233,10 +233,6 @@ func baseInboundTag(listen string, port int) string {
|
||||||
return fmt.Sprintf("inbound-%v:%v", listen, port)
|
return fmt.Sprintf("inbound-%v:%v", listen, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// transportTagSuffix turns a transport mask into a short, stable string.
|
|
||||||
// used both for generateInboundTag's disambiguation ("inbound-443-udp"
|
|
||||||
// when the base "inbound-443" is taken on a coexisting transport) and
|
|
||||||
// for the L4 hint in portConflictDetail's user-facing error message.
|
|
||||||
func transportTagSuffix(b transportBits) string {
|
func transportTagSuffix(b transportBits) string {
|
||||||
switch b {
|
switch b {
|
||||||
case transportTCP:
|
case transportTCP:
|
||||||
|
|
@ -249,29 +245,32 @@ func transportTagSuffix(b transportBits) string {
|
||||||
return "any"
|
return "any"
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateInboundTag picks a tag for the inbound that doesn't collide with
|
// nodeTagPrefix scopes a tag to one remote node so the same listen+port
|
||||||
// any existing row. for the common single-inbound-per-port case the tag
|
// can live on the central panel and on a node without bumping the global
|
||||||
// stays exactly as before ("inbound-443"), so user routing rules don't
|
// UNIQUE(inbounds.tag) constraint. nil → "" (local panel).
|
||||||
// silently change shape on upgrade. only when a same-port neighbour
|
func nodeTagPrefix(nodeID *int) string {
|
||||||
// already owns the base tag (now possible because tcp/443 and udp/443 can
|
if nodeID == nil {
|
||||||
// coexist after the transport-aware port check) does this append a
|
return ""
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("n%d-", *nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
|
// composeInboundTag returns the canonical
|
||||||
candidate := base + "-" + suffix
|
// "[n<id>-]inbound-[<listen>:]<port>-<transport>" shape used for every
|
||||||
exists, err = s.tagExists(candidate, ignoreId)
|
// newly created inbound. The transport segment lets tcp/443 and udp/443
|
||||||
|
// coexist; the node prefix lets the same port live on local + node.
|
||||||
|
func composeInboundTag(listen string, port int, nodeID *int, bits transportBits) string {
|
||||||
|
return nodeTagPrefix(nodeID) + baseInboundTag(listen, port) + "-" + transportTagSuffix(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateInboundTag returns a free tag in the canonical shape. ignoreId
|
||||||
|
// is the inbound's own id on update so it doesn't see itself as taken;
|
||||||
|
// pass 0 on add. Numeric suffix fallback is defensive — the port check
|
||||||
|
// should have already blocked an exact-collision insert.
|
||||||
|
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||||
|
bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
||||||
|
candidate := composeInboundTag(inbound.Listen, inbound.Port, inbound.NodeID, bits)
|
||||||
|
exists, err := s.tagExists(candidate, ignoreId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -279,9 +278,6 @@ func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int
|
||||||
return candidate, nil
|
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++ {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -292,8 +292,9 @@ func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// when the port is free, the historical "inbound-<port>" shape is kept
|
// when the port is free, the canonical tag includes the transport
|
||||||
// so existing routing rules don't change shape on upgrade.
|
// suffix so tcp/8443 and udp/8443 get distinct tags out of the box
|
||||||
|
// (no collision-driven retry needed at INSERT time).
|
||||||
func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
|
func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
|
|
||||||
|
|
@ -307,19 +308,21 @@ func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generateInboundTag: %v", err)
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
}
|
}
|
||||||
if got != "inbound-8443" {
|
if got != "inbound-8443-tcp" {
|
||||||
t.Fatalf("expected inbound-8443, got %q", got)
|
t.Fatalf("expected inbound-8443-tcp, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updating an inbound on its own port must not flag its own tag as
|
// updating an inbound on its own port must not flag its own tag as
|
||||||
// taken, that's what ignoreId is for.
|
// taken, that's what ignoreId is for. Seeds with the canonical
|
||||||
|
// "inbound-<port>-<transport>" shape so the self-update returns the
|
||||||
|
// same tag verbatim.
|
||||||
func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
|
func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
seedInboundConflict(t, "inbound-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
seedInboundConflict(t, "inbound-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
var existing model.Inbound
|
var existing model.Inbound
|
||||||
if err := database.GetDB().Where("tag = ?", "inbound-443").First(&existing).Error; err != nil {
|
if err := database.GetDB().Where("tag = ?", "inbound-443-tcp").First(&existing).Error; err != nil {
|
||||||
t.Fatalf("read seeded row: %v", err)
|
t.Fatalf("read seeded row: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,7 +331,7 @@ func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generateInboundTag: %v", err)
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
}
|
}
|
||||||
if got != "inbound-443" {
|
if got != "inbound-443-tcp" {
|
||||||
t.Fatalf("self-update must keep base tag, got %q", got)
|
t.Fatalf("self-update must keep base tag, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -424,8 +427,8 @@ func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// when the caller leaves Tag empty (the local UI path) resolveInboundTag
|
// when the caller leaves Tag empty (the local UI path) resolveInboundTag
|
||||||
// falls back to generateInboundTag, which keeps the historical
|
// falls back to generateInboundTag, which emits the canonical
|
||||||
// "inbound-<port>" shape so existing routing rules don't change.
|
// "inbound-<port>-<transport>" shape.
|
||||||
func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
|
func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
|
|
||||||
|
|
@ -439,8 +442,8 @@ func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveInboundTag: %v", err)
|
t.Fatalf("resolveInboundTag: %v", err)
|
||||||
}
|
}
|
||||||
if got != "inbound-8443" {
|
if got != "inbound-8443-tcp" {
|
||||||
t.Fatalf("expected generated inbound-8443, got %q", got)
|
t.Fatalf("expected generated inbound-8443-tcp, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -471,6 +474,51 @@ func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbounds bound to a remote node get the canonical tag prefixed with
|
||||||
|
// "n<id>-" so the same listen+port+transport can live on the central
|
||||||
|
// panel and on the node simultaneously without bumping the global
|
||||||
|
// UNIQUE(inbounds.tag) constraint.
|
||||||
|
func TestGenerateInboundTag_NodePrefix(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
in := &model.Inbound{
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
NodeID: intPtr(1),
|
||||||
|
}
|
||||||
|
got, err := svc.generateInboundTag(in, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
|
}
|
||||||
|
if got != "n1-inbound-443-tcp" {
|
||||||
|
t.Fatalf("expected n1-inbound-443-tcp, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a node-prefixed inbound shouldn't collide with a same-port local one:
|
||||||
|
// the prefix scopes the tag to that specific node.
|
||||||
|
func TestGenerateInboundTag_NodePrefixedDoesNotCollideWithLocal(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
seedInboundConflict(t, "inbound-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||||
|
|
||||||
|
svc := &InboundService{}
|
||||||
|
in := &model.Inbound{
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
NodeID: intPtr(1),
|
||||||
|
}
|
||||||
|
got, err := svc.generateInboundTag(in, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateInboundTag: %v", err)
|
||||||
|
}
|
||||||
|
if got != "n1-inbound-443-tcp" {
|
||||||
|
t.Fatalf("expected n1-inbound-443-tcp, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// updating an inbound must not see itself as a conflict, that's what
|
// updating an inbound must not see itself as a conflict, that's what
|
||||||
// ignoreId is for.
|
// ignoreId is for.
|
||||||
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
|
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue