mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
5cf2ab4f69
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.
472 lines
15 KiB
Go
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
|
|
}
|