mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-08 15:12:07 +03:00
b3f1cced70
Keep sniffed TLS domains available for routing without replacing the destination address requested by the TUIC client. Add a regression assertion for the generated relay sniffing settings.
350 lines
11 KiB
Go
350 lines
11 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 TestInjectTuicSocks(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}
|
|
]
|
|
}`,
|
|
},
|
|
}
|
|
|
|
injectTuicSocks(cfg, inbounds)
|
|
|
|
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"`
|
|
}
|
|
if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
|
|
t.Fatalf("failed to unmarshal settings: %v", err)
|
|
}
|
|
if parsedSettings.Auth != "noauth" || !parsedSettings.UDP {
|
|
t.Fatalf("expected auth=noauth, udp=true, got %+v", parsedSettings)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|