Files
3x-ui/internal/web/service/inbound_tuic_test.go
T
MHSanaei 5cf2ab4f69 fix(tuic): bound the timeout settings at a day
max_idle_time and authentication_timeout are second counts multiplied
into a Duration with no bound, so a stored value past the int64 range
wrapped negative and the QUIC listener panicked on the first
ClientHello, taking the panel down at every start until the row was
fixed. Saves now refuse more than a day, and a stored row past it fails
its own listener start instead.
2026-10-31 14:53:20 +01:00

472 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")
}
for _, settings := range []string{`{"max_idle_time":2199023255552}`, `{"server":{"authentication_timeout":86401}}`} {
ib.Settings = settings
if err := normalizeTuicSettings(ib); err == nil {
t.Fatalf("a timeout past a day was accepted: %s", settings)
}
}
}
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
}