From f18419da8b8da3454fc32a4531ef0855b6f62160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?= Date: Mon, 5 Oct 2026 16:51:18 +0800 Subject: [PATCH] fix(listener): allow TCP and UDP listeners to share a port (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint conflict guard in ensureEndpointAvailable (and its batch counterpart ensureBatchEndpointsAvailable) compared only port ranges and bind addresses — it never looked at the L4 protocol. A TCP listener on 443 and a UDP listener on 443 are independent sockets at the OS level and Mihomo serves both fine, but 3m-ui rejected the second one with "conflicts on 0.0.0.0:443". So you could not run e.g. VLESS (TCP) and TUIC/Hysteria2 (UDP) on the same port. Add protocol.ListenerTransports, which maps a listener's protocol kind to the L4 transport(s) its inbound binds (UDP for tuic / hysteria / hysteria2 / shadowquic / mieru; TCP for everything else). The proxy `udp` toggle is UDP relay over an established connection and is intentionally ignored — it does not open a UDP listening socket. The conflict check now also requires the two listeners to share an L4 transport, so a TCP+UDP pair on the same address:port no longer collides while two TCP or two UDP listeners still do. Tests: - protocol.ListenerTransports unit tests (udp/tcp/case-insensitive). - listener.listenersShareTransport unit tests. - End-to-end: a TCP+UDP pair on 443 coexists and the UDP listener stays on 443 through Create (it is not silently reassigned to a free port), while two TCP or two UDP on 443 still conflict. Co-authored-by: 風雪 --- .../listener/endpoint_conflict_test.go | 72 +++++++++++++++++++ backend/internal/listener/endpoints.go | 16 +++++ backend/internal/listener/endpoints_test.go | 26 +++++++ backend/internal/listener/p2.go | 2 +- backend/internal/listener/service.go | 10 ++- backend/internal/protocol/transport.go | 30 ++++++++ backend/internal/protocol/transport_test.go | 32 +++++++++ 7 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 backend/internal/listener/endpoint_conflict_test.go create mode 100644 backend/internal/listener/endpoints_test.go create mode 100644 backend/internal/protocol/transport.go create mode 100644 backend/internal/protocol/transport_test.go diff --git a/backend/internal/listener/endpoint_conflict_test.go b/backend/internal/listener/endpoint_conflict_test.go new file mode 100644 index 0000000..a11bf26 --- /dev/null +++ b/backend/internal/listener/endpoint_conflict_test.go @@ -0,0 +1,72 @@ +package listener + +import ( + "path/filepath" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database" + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +// A TCP listener (vless) and a UDP listener (tuic) on the same address:port must +// not be flagged as a conflict: they bind independent sockets at the OS level. +// The old guard rejected the second one with "conflicts on 0.0.0.0:443". +func TestEnsureEndpointAvailableTCPvsUDP(t *testing.T) { + db, err := database.InitDB(filepath.Join(t.TempDir(), "panel.db")) + if err != nil { + t.Fatal(err) + } + defer func() { sqlDB, _ := db.DB(); _ = sqlDB.Close() }() + + svc := NewService(db, filepath.Join(t.TempDir(), "config.yaml"), nil) + + tcp := &models.Listener{Name: "vless-443", Protocol: "vless", Port: "443", BindAddress: "0.0.0.0", Enabled: true, Config: `{}`} + if err := svc.Create(tcp); err != nil { + t.Fatalf("seed TCP listener: %v", err) + } + + // UDP on the same address:port must be allowed (independent socket). + udp := &models.Listener{Name: "tuic-443", Protocol: "tuic", Port: "443", BindAddress: "0.0.0.0", Enabled: true, Config: `{"token":["aaaaaaaaaaaaaaaa"]}`} + if err := svc.ensureEndpointAvailable(udp); err != nil { + t.Fatalf("TCP+UDP on 443 wrongly flagged as conflict: %v", err) + } + if err := svc.Create(udp); err != nil { + t.Fatalf("create UDP listener on 443: %v", err) + } + + // Two TCP on the same address:port must still conflict (checked vs the seeded vless). + tcp2 := &models.Listener{Name: "vless-443-b", Protocol: "vless", Port: "443", BindAddress: "0.0.0.0", Enabled: true, Config: `{}`} + if err := svc.ensureEndpointAvailable(tcp2); err == nil { + t.Fatal("expected conflict between two TCP listeners on 443, got nil") + } + + // Two UDP on the same address:port must still conflict (checked vs the seeded tuic). + udp2 := &models.Listener{Name: "hy2-443", Protocol: "hysteria2", Port: "443", BindAddress: "0.0.0.0", Enabled: true, Config: `{"password":"test-pass"}`} + if err := svc.ensureEndpointAvailable(udp2); err == nil { + t.Fatal("expected conflict between two UDP listeners on 443, got nil") + } +} + +// End-to-end through Create: the UDP listener must stay bound to 443 (not be +// silently reassigned to a free port by the conflict-retry path). +func TestCreateKeepsUDPOnSamePort(t *testing.T) { + db, err := database.InitDB(filepath.Join(t.TempDir(), "panel.db")) + if err != nil { + t.Fatal(err) + } + defer func() { sqlDB, _ := db.DB(); _ = sqlDB.Close() }() + + svc := NewService(db, filepath.Join(t.TempDir(), "config.yaml"), nil) + + tcp := &models.Listener{Name: "vless-443", Protocol: "vless", Port: "443", BindAddress: "0.0.0.0", Enabled: true, Config: `{}`} + if err := svc.Create(tcp); err != nil { + t.Fatalf("seed TCP listener: %v", err) + } + udp := &models.Listener{Name: "tuic-443", Protocol: "tuic", Port: "443", BindAddress: "0.0.0.0", Enabled: true, Config: `{"token":["aaaaaaaaaaaaaaaa"]}`} + if err := svc.Create(udp); err != nil { + t.Fatalf("UDP listener on 443 rejected: %v", err) + } + if udp.Port != "443" { + t.Fatalf("UDP listener was reassigned off 443 (got port %q); TCP+UDP should coexist", udp.Port) + } +} diff --git a/backend/internal/listener/endpoints.go b/backend/internal/listener/endpoints.go index b3678f9..f071380 100644 --- a/backend/internal/listener/endpoints.go +++ b/backend/internal/listener/endpoints.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" + "github.com/kazeyukiro/3m-ui/backend/internal/protocol" ) func portRanges(value string) ([][2]int, bool) { @@ -80,3 +81,18 @@ func listenerAddressesConflict(a, b string) bool { } return ia.Equal(ib) } + +// listenersShareTransport reports whether two listeners bind a common L4 transport +// on their listening socket (per protocol.ListenerTransports). A TCP listener and a +// UDP listener on the same address:port are independent sockets and must not be +// reported as a conflict, so they return false here. +func listenersShareTransport(a, b models.Listener) bool { + for _, x := range protocol.ListenerTransports(a.Protocol) { + for _, y := range protocol.ListenerTransports(b.Protocol) { + if x == y { + return true + } + } + } + return false +} diff --git a/backend/internal/listener/endpoints_test.go b/backend/internal/listener/endpoints_test.go new file mode 100644 index 0000000..ecd8c52 --- /dev/null +++ b/backend/internal/listener/endpoints_test.go @@ -0,0 +1,26 @@ +package listener + +import ( + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +func TestListenersShareTransport(t *testing.T) { + cases := []struct { + name string + a, b models.Listener + want bool + }{ + {"tcp+tcp", models.Listener{Protocol: "vless"}, models.Listener{Protocol: "trojan"}, true}, + {"udp+udp", models.Listener{Protocol: "tuic"}, models.Listener{Protocol: "hysteria2"}, true}, + {"tcp+udp", models.Listener{Protocol: "vless"}, models.Listener{Protocol: "tuic"}, false}, + {"udp+tcp", models.Listener{Protocol: "mieru"}, models.Listener{Protocol: "shadowsocks"}, false}, + {"vless+tuic", models.Listener{Protocol: "vless"}, models.Listener{Protocol: "tuic-v4"}, false}, + } + for _, c := range cases { + if got := listenersShareTransport(c.a, c.b); got != c.want { + t.Fatalf("%s: listenersShareTransport = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go index f4de309..338aa6f 100644 --- a/backend/internal/listener/p2.go +++ b/backend/internal/listener/p2.go @@ -157,7 +157,7 @@ func (s *Service) ensureBatchEndpointsAvailable(created []models.Listener) error return err } for j := i + 1; j < len(created); j++ { - if portsOverlap(created[i].Port, created[j].Port) && listenerAddressesConflict(firstListenerAddress(created[i]), firstListenerAddress(created[j])) { + if portsOverlap(created[i].Port, created[j].Port) && listenerAddressesConflict(firstListenerAddress(created[i]), firstListenerAddress(created[j])) && listenersShareTransport(created[i], created[j]) { return fmt.Errorf("listeners %q and %q have conflicting endpoints", created[i].Name, created[j].Name) } } diff --git a/backend/internal/listener/service.go b/backend/internal/listener/service.go index 9b8bc5d..41a7758 100644 --- a/backend/internal/listener/service.go +++ b/backend/internal/listener/service.go @@ -289,9 +289,15 @@ func (s *Service) ensureEndpointAvailable(candidate *models.Listener) error { if !portsOverlap(candidate.Port, existing.Port) { continue } - if listenerAddressesConflict(firstListenerAddress(*candidate), firstListenerAddress(existing)) { - return fmt.Errorf("listener %q conflicts with existing listener %q on %s:%s", candidate.Name, existing.Name, firstListenerAddress(existing), existing.Port) + if !listenerAddressesConflict(firstListenerAddress(*candidate), firstListenerAddress(existing)) { + continue } + // TCP and UDP listeners on the same address:port are independent sockets + // and must not be treated as a conflict. + if !listenersShareTransport(*candidate, existing) { + continue + } + return fmt.Errorf("listener %q conflicts with existing listener %q on %s:%s", candidate.Name, existing.Name, firstListenerAddress(existing), existing.Port) } return nil } diff --git a/backend/internal/protocol/transport.go b/backend/internal/protocol/transport.go new file mode 100644 index 0000000..148c312 --- /dev/null +++ b/backend/internal/protocol/transport.go @@ -0,0 +1,30 @@ +package protocol + +import "strings" + +// udpBoundKinds are the listener protocol kinds whose inbound binds a UDP socket +// in Mihomo. Every other kind binds a TCP listening socket. The proxy `udp` +// toggle exposed on the listener (UDP relay over an established connection) is +// unrelated to the listening socket and is intentionally ignored here. +var udpBoundKinds = map[string]struct{}{ + "tuic": {}, + "tuic-v4": {}, + "tuic-v5": {}, + "hysteria": {}, + "hysteria2": {}, + "shadowquic": {}, + "mieru": {}, +} + +// ListenerTransports reports the L4 transports a listener binds a socket on, +// derived from its protocol kind. It returns ["udp"] for UDP-bound protocols and +// ["tcp"] for everything else. Callers use this so a TCP listener on port 443 and +// a UDP listener on port 443 are treated as independent sockets rather than a +// port conflict — they are separate endpoints at the OS level. +func ListenerTransports(protocol string) []string { + kind := strings.ToLower(strings.TrimSpace(protocol)) + if _, ok := udpBoundKinds[kind]; ok { + return []string{"udp"} + } + return []string{"tcp"} +} diff --git a/backend/internal/protocol/transport_test.go b/backend/internal/protocol/transport_test.go new file mode 100644 index 0000000..e18db28 --- /dev/null +++ b/backend/internal/protocol/transport_test.go @@ -0,0 +1,32 @@ +package protocol + +import ( + "testing" +) + +func TestListenerTransportsUDPKinds(t *testing.T) { + for _, kind := range []string{"tuic", "tuic-v4", "tuic-v5", "hysteria", "hysteria2", "shadowquic", "mieru"} { + got := ListenerTransports(kind) + if len(got) != 1 || got[0] != "udp" { + t.Fatalf("ListenerTransports(%q) = %v, want [udp]", kind, got) + } + } +} + +func TestListenerTransportsTCPKinds(t *testing.T) { + for _, kind := range []string{"vless", "vmess", "trojan", "shadowsocks", "snell", "anytls", "sudoku", "trusttunnel", "socks", "http"} { + got := ListenerTransports(kind) + if len(got) != 1 || got[0] != "tcp" { + t.Fatalf("ListenerTransports(%q) = %v, want [tcp]", kind, got) + } + } +} + +func TestListenerTransportsCaseInsensitive(t *testing.T) { + if got := ListenerTransports(" TUIC-V4 "); got[0] != "udp" { + t.Fatalf("ListenerTransports(uppercase) = %v, want [udp]", got) + } + if got := ListenerTransports(""); got[0] != "tcp" { + t.Fatalf("ListenerTransports(empty) = %v, want [tcp]", got) + } +}