feat(port-conflict): include offending inbound + L4 in the error, cover quic and tunnel.allowedNetwork

checkPortConflict used to return a bare bool, which the API layer
translated into "Port already exists: 443" with no hint about which
existing inbound owned the port, what listen address it used, or
which L4 transport actually clashed. On a panel with dozens of
inbounds the admin had to scan the list by hand to figure out the
collision.

Return a portConflictDetail{InboundID, Remark, Tag, Listen, Port,
Transports} instead; a String() method formats it as
"port 443 (tcp) already used by inbound 'my-vless' (#7) on *" so the
existing common.NewError wrapping carries the full context up to the
UI without a second round-trip.

Two predicate gaps fixed at the same time:
- streamSettings.network="quic" rides on UDP the same way "kcp" does,
  so it now joins KCP in the UDP branch instead of falling through to
  the TCP default (a QUIC inbound used to silently allow a UDP
  neighbour on the same port).
- Tunnel reads settings.allowedNetwork ("tcp" / "udp" / "tcp,udp"),
  not settings.network — 3x-ui's dokodemo-door wrapper renames the
  field, and treating it as Shadowsocks-shaped left every Tunnel
  inbound looking like plain TCP regardless of what the admin
  configured.

Tests: TCP/UDP coexist + same-transport collision matrix already
covered the happy path; added QUICTreatedAsUDP, TunnelAllowedNetwork,
and DetailMessage to lock in the new behaviour. Dropped the unused
transportBits.conflicts() helper now that the call site composes the
mask itself to populate the detail.
This commit is contained in:
MHSanaei
2026-05-27 12:56:15 +02:00
parent 96a5c73e02
commit 980511bcad
5 changed files with 815 additions and 653 deletions
+118 -11
View File
@@ -2,6 +2,7 @@ package service
import (
"path/filepath"
"strings"
"sync"
"testing"
@@ -137,7 +138,7 @@ func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) {
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if exist {
if exist != nil {
t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist")
}
}
@@ -159,7 +160,7 @@ func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) {
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if !exist {
if exist == nil {
t.Fatalf("two tcp inbounds on the same port must still conflict")
}
}
@@ -181,7 +182,7 @@ func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) {
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if !exist {
if exist == nil {
t.Fatalf("two udp inbounds on the same port must conflict")
}
}
@@ -201,7 +202,7 @@ func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
}
if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || !exist {
if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || exist == nil {
t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
}
@@ -211,7 +212,7 @@ func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
Port: 443,
Protocol: model.Hysteria,
}
if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist {
if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || exist == nil {
t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
}
}
@@ -229,7 +230,7 @@ func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) {
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
}
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err)
}
}
@@ -251,7 +252,7 @@ func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
}
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist {
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err)
}
@@ -263,7 +264,7 @@ func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
}
if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || !exist {
if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || exist == nil {
t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err)
}
}
@@ -386,8 +387,8 @@ func TestCheckPortConflict_NodeScope(t *testing.T) {
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if got != c.want {
t.Fatalf("got conflict=%v, want %v", got, c.want)
if (got != nil) != c.want {
t.Fatalf("got conflict=%v, want %v", got != nil, c.want)
}
})
}
@@ -482,7 +483,113 @@ func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
}
svc := &InboundService{}
if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist {
if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist != nil {
t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err)
}
}
// streamSettings.network=quic rides on UDP at L4, so a QUIC inbound must
// conflict with a UDP-only neighbour (hysteria) on the same port but not
// with a TCP-only one. covers the gap left by the original kcp-only check.
func TestCheckPortConflict_QUICTreatedAsUDP(t *testing.T) {
quic := &model.Inbound{
Tag: "vless-quic-443",
Listen: "0.0.0.0",
Port: 443,
Protocol: model.VLESS,
StreamSettings: `{"network":"quic"}`,
}
t.Run("conflicts with hysteria/udp", func(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "hyst-443", "0.0.0.0", 443, model.Hysteria, ``, ``)
svc := &InboundService{}
if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist == nil {
t.Fatalf("quic on same port as hysteria must conflict; exist=%v err=%v", exist, err)
}
})
t.Run("coexists with vless/tcp", func(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "vless-tcp-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{}
if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist != nil {
t.Fatalf("quic and tcp on same port must coexist; exist=%v err=%v", exist, err)
}
})
}
// tunnel (dokodemo-door) carries its L4 transport list in
// settings.allowedNetwork, not settings.network. verify the predicate
// picks the right field for each protocol.
func TestCheckPortConflict_TunnelAllowedNetwork(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "tunnel-udp-443", "0.0.0.0", 443, model.Tunnel, ``, `{"allowedNetwork":"udp"}`)
svc := &InboundService{}
// tcp inbound on same port should coexist with udp-only tunnel.
tcpNeighbour := &model.Inbound{
Tag: "vless-443",
Listen: "0.0.0.0",
Port: 443,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
}
if exist, err := svc.checkPortConflict(tcpNeighbour, 0); err != nil || exist != nil {
t.Fatalf("tunnel/udp and vless/tcp on same port must coexist; exist=%v err=%v", exist, err)
}
// udp neighbour (hysteria) on same port must conflict.
udpNeighbour := &model.Inbound{
Tag: "hyst-443",
Listen: "0.0.0.0",
Port: 443,
Protocol: model.Hysteria,
}
if exist, err := svc.checkPortConflict(udpNeighbour, 0); err != nil || exist == nil {
t.Fatalf("tunnel/udp and hysteria on same port must conflict; exist=%v err=%v", exist, err)
}
}
// the rich conflict detail surfaced to the user must name the offending
// inbound (by remark when available) and the shared L4 transport(s).
func TestCheckPortConflict_DetailMessage(t *testing.T) {
setupConflictDB(t)
seeded := &model.Inbound{
Tag: "vless-443",
Remark: "my-vless",
Enable: true,
Listen: "0.0.0.0",
Port: 443,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
Settings: `{}`,
}
if err := database.GetDB().Create(seeded).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "trojan-443",
Listen: "0.0.0.0",
Port: 443,
Protocol: model.Trojan,
StreamSettings: `{"network":"ws"}`,
}
got, err := svc.checkPortConflict(candidate, 0)
if err != nil || got == nil {
t.Fatalf("expected conflict, got=%v err=%v", got, err)
}
msg := got.String()
if !strings.Contains(msg, "my-vless") {
t.Fatalf("message should mention the conflicting inbound's remark; got %q", msg)
}
if !strings.Contains(msg, "tcp") {
t.Fatalf("message should mention the shared L4 transport; got %q", msg)
}
if !strings.Contains(msg, "443") {
t.Fatalf("message should mention the port; got %q", msg)
}
}