mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 23:35:00 +03:00
207c705300
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.
466 lines
15 KiB
Go
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
|
|
}
|