fix(listener): allow TCP and UDP listeners to share a port (#91)
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: 風雪 <kazeyukiro@users.noreply.github.com>
This commit is contained in:
1 parent
ee14b06680
commit
f18419da8b
7 files changed
+185
-3
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user