Files
3x-ui/internal/web/service/inbound_tuic_test.go
T
Egor 207c705300 fix(tuic): preserve client identity for Xray user routing (#6875)
TUIC traffic reached Xray through a loopback SOCKS relay that took no login,
so Xray never learned which client a connection belonged to and user routing
rules never matched TUIC (#6874). The relay now carries one account per client
email, so per-user rules match on TCP and both UDP modes; admission stays with
the native TUIC server.

Adding, removing or renaming a TUIC client swaps only that inbound's relay
instead of restarting Xray: new sessions wait behind a bounded gate while live
streams and UDP associations continue, accounts are verified after the swap and
rolled back on failure, one failing relay no longer strands the others or blocks
unrelated live changes, and emails over SOCKS5's 255-byte limit are excluded
with a warning.
2026-10-24 06:20:39 +02:00

466 lines
15 KiB
Go

package service
import (
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestInjectTuicRelay(t *testing.T) {
cfg := &xray.Config{}
inbounds := []*model.Inbound{
{
Id: 5,
Tag: "tuic-in-5",
Protocol: model.TUIC,
Enable: true,
Settings: `{
"certificate": "dummy-cert",
"private_key": "dummy-key",
"clients": [
{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "pass1", "email": "user1@example.com", "enable": true}
]
}`,
},
}
inst, ok := tuic.InstanceFromInbound(inbounds[0])
if !ok {
t.Fatal("invalid TUIC fixture")
}
if err := injectTuicRelay(cfg, []tuic.Instance{inst}); err != nil {
t.Fatal(err)
}
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("expected 1 injected SOCKS inbound, got %d", len(cfg.InboundConfigs))
}
sc := cfg.InboundConfigs[0]
if sc.Tag != "tuic-in-5" {
t.Fatalf("expected tag tuic-in-5, got %s", sc.Tag)
}
if sc.Protocol != "socks" {
t.Fatalf("expected protocol socks, got %s", sc.Protocol)
}
expectedPort := tuic.SOCKSPortForInbound(5)
if sc.Port != expectedPort {
t.Fatalf("expected port %d, got %d", expectedPort, sc.Port)
}
if string(sc.Listen) != `"127.0.0.1"` {
t.Fatalf("expected listen 127.0.0.1, got %s", sc.Listen)
}
var parsedSniffing struct {
Enabled bool `json:"enabled"`
DestOverride []string `json:"destOverride"`
RouteOnly bool `json:"routeOnly"`
}
if err := json.Unmarshal(sc.Sniffing, &parsedSniffing); err != nil {
t.Fatalf("failed to unmarshal sniffing settings: %v", err)
}
if !parsedSniffing.Enabled || !parsedSniffing.RouteOnly {
t.Fatalf("sniffing must be enabled with routeOnly, got %+v", parsedSniffing)
}
if want := []string{"http", "tls", "quic", "fakedns"}; !slices.Equal(parsedSniffing.DestOverride, want) {
t.Fatalf("destOverride = %v, want %v", parsedSniffing.DestOverride, want)
}
var parsedSettings struct {
Auth string `json:"auth"`
UDP bool `json:"udp"`
Accounts []struct {
User string `json:"user"`
Pass string `json:"pass"`
} `json:"accounts"`
}
if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
t.Fatalf("failed to unmarshal settings: %v", err)
}
if parsedSettings.Auth != "password" || !parsedSettings.UDP {
t.Fatalf("expected auth=password, udp=true, got %+v", parsedSettings)
}
if len(parsedSettings.Accounts) != 1 || parsedSettings.Accounts[0].User != "user1@example.com" || parsedSettings.Accounts[0].Pass != tuic.SocksPassword() {
t.Fatal("generated SOCKS account must authenticate the TUIC client using the relay password")
}
}
func TestCheckTuicSocksConflict(t *testing.T) {
setupConflictDB(t)
// Seed TUIC inbound with ID 10
tuicIb := &model.Inbound{
Id: 10,
Tag: "tuic-10",
Protocol: model.TUIC,
Enable: true,
Listen: "0.0.0.0",
Port: 8443,
Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"u@test.com"}]}`,
}
if err := database.GetDB().Create(tuicIb).Error; err != nil {
t.Fatalf("failed to seed TUIC inbound: %v", err)
}
relayPort := tuic.SOCKSPortForInbound(10)
// Try to create a new TCP inbound on that relayPort on 127.0.0.1
newIb := &model.Inbound{
Tag: "colliding-inbound",
Protocol: model.Mixed,
Enable: true,
Listen: "127.0.0.1",
Port: relayPort,
}
detail, err := checkTuicSocksConflict(database.GetDB(), newIb, 0, transportTCP)
if err != nil {
t.Fatalf("checkTuicSocksConflict error: %v", err)
}
if detail == nil {
t.Fatalf("expected conflict on port %d, got none", relayPort)
}
if detail.Tag != "tuic-10" {
t.Fatalf("expected conflict tag tuic-10, got %s", detail.Tag)
}
}
func TestCheckTuicSocksReverseConflict(t *testing.T) {
setupConflictDB(t)
targetPort := tuic.SOCKSPortForInbound(20)
// Seed existing inbound on targetPort on 127.0.0.1
existing := &model.Inbound{
Id: 99,
Tag: "existing-on-relay-port",
Protocol: model.Mixed,
Enable: true,
Listen: "127.0.0.1",
Port: targetPort,
}
if err := database.GetDB().Create(existing).Error; err != nil {
t.Fatalf("failed to seed existing inbound: %v", err)
}
detail, err := checkTuicSocksReverseConflict(database.GetDB(), 20)
if err != nil {
t.Fatalf("checkTuicSocksReverseConflict error: %v", err)
}
if detail == nil {
t.Fatalf("expected reverse conflict for id 20 on port %d, got none", targetPort)
}
if detail.Tag != "existing-on-relay-port" {
t.Fatalf("expected tag existing-on-relay-port, got %s", detail.Tag)
}
}
func TestDesiredTuicInstances(t *testing.T) {
setupConflictDB(t)
ib := &model.Inbound{
Id: 30,
Tag: "tuic-desired-test",
Protocol: model.TUIC,
Enable: true,
Listen: "0.0.0.0",
Port: 9443,
Settings: `{
"certificate": "cert",
"private_key": "key",
"clients": [
{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "p1", "email": "active@test.com", "enable": true},
{"uuid": "a0000000-0000-0000-0000-000000000002", "password": "p2", "email": "disabled@test.com", "enable": true}
]
}`,
}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("failed to seed inbound: %v", err)
}
// Add client traffic entry disabling disabled@test.com
ct := &xray.ClientTraffic{
InboundId: 30,
Email: "disabled@test.com",
Enable: false,
}
if err := database.GetDB().Create(ct).Error; err != nil {
t.Fatalf("failed to seed client traffic: %v", err)
}
svc := &InboundService{}
instances, err := svc.DesiredTuicInstances()
if err != nil {
t.Fatalf("DesiredTuicInstances failed: %v", err)
}
found := false
for _, inst := range instances {
if inst.Id == 30 {
found = true
if len(inst.Clients) != 1 || inst.Clients[0].Email != "active@test.com" {
t.Fatalf("expected only active@test.com, got %+v", inst.Clients)
}
}
}
if !found {
t.Fatal("expected to find instance for inbound 30")
}
}
func TestCheckForwardedPortsConflict_CollidesWithTuicSocksPort(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "tuic-1", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
var tuicInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "tuic-1").First(&tuicInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := tuic.SOCKSPortForInbound(tuicInbound.Id)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB(), nil)
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
if !strings.Contains(hit, "SOCKS5") {
t.Fatalf("expected a collision naming the TUIC inbound's SOCKS5 relay port, got %q", hit)
}
}
func TestCheckTuicSocksConflict_DisabledInboundRetainsReservation(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "tuic-disabled", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
var tuicIb model.Inbound
if err := database.GetDB().Where("tag = ?", "tuic-disabled").First(&tuicIb).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
if err := database.GetDB().Model(&tuicIb).Update("enable", false).Error; err != nil {
t.Fatalf("disable inbound: %v", err)
}
relayPort := tuic.SOCKSPortForInbound(tuicIb.Id)
testIb := &model.Inbound{
Tag: "conflict-test",
Protocol: model.VLESS,
Listen: "127.0.0.1",
Port: relayPort,
Enable: true,
}
conflict, err := checkTuicSocksConflict(database.GetDB(), testIb, 0, transportTCP)
if err != nil {
t.Fatalf("checkTuicSocksConflict: %v", err)
}
if conflict == nil {
t.Fatal("expected conflict on disabled TUIC inbound's SOCKS port, got nil")
}
if conflict.InboundID != tuicIb.Id {
t.Fatalf("expected conflict with inbound %d, got %d", tuicIb.Id, conflict.InboundID)
}
}
func TestCheckTuicSocksRelayCollision(t *testing.T) {
setupConflictDB(t)
// Seed first TUIC inbound with ID 1
ib1 := &model.Inbound{
Id: 1,
Tag: "tuic-1",
Protocol: model.TUIC,
Enable: true,
Listen: "0.0.0.0",
Port: 8443,
Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"u1@test.com"}]}`,
}
if err := database.GetDB().Create(ib1).Error; err != nil {
t.Fatalf("seed ib1: %v", err)
}
// ID 1001 wraps to the same relay port (64001) as ID 1
conflict, err := checkTuicSocksRelayCollision(database.GetDB(), 1001)
if err != nil {
t.Fatalf("checkTuicSocksRelayCollision: %v", err)
}
if conflict == nil {
t.Fatal("expected collision between ID 1001 and ID 1, got nil")
}
if conflict.InboundID != 1 {
t.Fatalf("expected collision with inbound 1, got %d", conflict.InboundID)
}
}
func TestTuicSocksSelfConflict(t *testing.T) {
ib := &model.Inbound{
Protocol: model.TUIC,
Listen: "127.0.0.1",
Port: tuic.SOCKSPortForInbound(5),
}
errStr := tuicSocksSelfConflict(ib, 5)
if errStr == "" {
t.Fatal("expected self conflict error string, got empty")
}
if !strings.Contains(errStr, "own SOCKS5 relay port") {
t.Fatalf("unexpected error string: %s", errStr)
}
// Different port should not conflict
ib.Port = 9999
if diff := tuicSocksSelfConflict(ib, 5); diff != "" {
t.Fatalf("expected no conflict for different port, got %s", diff)
}
}
func TestInboundTuicServerParsesLegacyFlatSettings(t *testing.T) {
server := inboundTuicServer(string(model.TUIC), `{"certificate":"/cert.pem","private_key":"/secret-key.pem","congestion_control":" CuBiC ","udp_relay_mode":"quic","sni":"profile.example"}`)
if server == nil {
t.Fatal("expected legacy flat TUIC settings")
}
if server.CongestionControl != "cubic" || server.UDPRelayMode != "quic" || server.SNI != "profile.example" {
t.Fatalf("legacy flat fields were not normalized: %+v", server)
}
if server.PrivateKey != "" {
t.Fatal("client preview exposed the inbound private key")
}
}
func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T) {
ib := &model.Inbound{Protocol: model.TUIC, Settings: `{"congestion_control":"RENO","max_udp_relay_packet_size":65507,"server":{"congestion_control":" CuBiC ","max_udp_relay_packet_size":65500}}`}
if err := normalizeTuicSettings(ib); err != nil {
t.Fatalf("normalizeTuicSettings: %v", err)
}
var got struct {
CongestionControl string `json:"congestion_control"`
MaxPacketSize int `json:"max_udp_relay_packet_size"`
Server struct {
CongestionControl string `json:"congestion_control"`
MaxPacketSize int `json:"max_udp_relay_packet_size"`
} `json:"server"`
}
if err := json.Unmarshal([]byte(ib.Settings), &got); err != nil {
t.Fatalf("unmarshal normalized settings: %v", err)
}
if got.CongestionControl != "new_reno" || got.Server.CongestionControl != "cubic" {
t.Fatalf("congestion controllers were not canonicalized: %+v", got)
}
if got.MaxPacketSize != 65245 || got.Server.MaxPacketSize != 65245 {
t.Fatalf("packet limits were not clamped: %+v", got)
}
ib.Settings = `{"server":{"congestion_control":"experimental"}}`
if err := normalizeTuicSettings(ib); err == nil {
t.Fatal("unsupported congestion controller was accepted")
}
}
func TestTuicRelayIdentitySurvivesNativeEligibilityChanges(t *testing.T) {
setupBulkDB(t)
clients := []model.Client{
{Email: "active@test", ID: "a0000000-0000-0000-0000-000000000071", Password: "p1", Enable: true},
{Email: "manual@test", ID: "a0000000-0000-0000-0000-000000000072", Password: "p2", Enable: false},
{Email: "quota@test", ID: "a0000000-0000-0000-0000-000000000073", Password: "p3", Enable: true},
{Email: "invalid@test", ID: "a0000000-0000-0000-0000-000000000074", Enable: true},
}
ib := mkInbound(t, 45371, model.TUIC, clientsSettings(t, clients))
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatal(err)
}
for _, c := range clients {
if err := (&InboundService{}).AddClientStat(database.GetDB(), ib.Id, &c); err != nil {
t.Fatal(err)
}
}
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "quota@test").Update("enable", false).Error; err != nil {
t.Fatal(err)
}
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
if got := tuicRelayAccountEmails(t, cfg, ib.Tag); !slices.Equal(got, []string{"active@test", "manual@test", "quota@test"}) {
t.Fatalf("relay accounts = %v, want configured identities independent of native eligibility", got)
}
instances, err := (&InboundService{}).DesiredTuicInstances()
if err != nil || len(instances) != 1 || len(instances[0].Clients) != 1 || instances[0].Clients[0].Email != "active@test" {
t.Fatalf("native TUIC eligibility differs from relay accounts: %+v, %v", instances, err)
}
var settings map[string]any
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
t.Fatal(err)
}
settings["sni"] = "profile.test"
settings["udp_relay_mode"] = "quic"
settings["congestion_control"] = "cubic"
settings["log_level"] = "debug"
if err := database.GetDB().Model(ib).Update("settings", string(mustJSON(t, settings))).Error; err != nil {
t.Fatal(err)
}
profileCfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
if diff, hot := xray.ComputeHotDiff(cfg, profileCfg); !hot || !diff.Empty() {
t.Fatalf("TUIC profile/runtime settings changed Xray config: hot=%t diff=%+v", hot, diff)
}
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "quota@test").Update("enable", true).Error; err != nil {
t.Fatal(err)
}
renewedCfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
if got := tuicRelayAccountEmails(t, renewedCfg, ib.Tag); !slices.Equal(got, []string{"active@test", "manual@test", "quota@test"}) {
t.Fatalf("renewed relay accounts = %v, want all configured identities", got)
}
if diff, hot := xray.ComputeHotDiff(profileCfg, renewedCfg); !hot || !diff.Empty() {
t.Fatal("quota renewal must leave relay admission unchanged")
}
if err := database.GetDB().Model(ib).Update("settings", `{"clients":[]}`).Error; err != nil {
t.Fatal(err)
}
emptyCfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
for _, inbound := range emptyCfg.InboundConfigs {
if inbound.Tag == ib.Tag && len(tuicRelayAccountEmails(t, emptyCfg, ib.Tag)) != 0 {
t.Fatal("removed clients must lose relay credentials")
}
}
}
func tuicRelayAccountEmails(t *testing.T, cfg *xray.Config, tag string) []string {
t.Helper()
for _, ib := range cfg.InboundConfigs {
if ib.Tag != tag {
continue
}
var settings struct {
Accounts []struct {
User string `json:"user"`
} `json:"accounts"`
}
if err := json.Unmarshal(ib.Settings, &settings); err != nil {
t.Fatal(err)
}
emails := make([]string, 0, len(settings.Accounts))
for _, account := range settings.Accounts {
emails = append(emails, account.User)
}
return emails
}
t.Fatalf("missing generated TUIC relay %q", tag)
return nil
}