Files
3x-ui/internal/web/service/tuic_user_routing_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

558 lines
20 KiB
Go

package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/quic-go/quic-go"
_ "github.com/xtls/xray-core/app/commander"
_ "github.com/xtls/xray-core/app/dispatcher"
_ "github.com/xtls/xray-core/app/proxyman/inbound"
_ "github.com/xtls/xray-core/app/proxyman/outbound"
"github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/features/stats"
"github.com/xtls/xray-core/infra/conf"
_ "github.com/xtls/xray-core/proxy/blackhole"
_ "github.com/xtls/xray-core/proxy/dokodemo"
_ "github.com/xtls/xray-core/proxy/freedom"
_ "github.com/xtls/xray-core/proxy/socks"
"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"
)
const tuicRoutingProbe = "tuic-route-probe"
func TestTuicUserRoutingDoesNotDoubleCountTraffic(t *testing.T) {
setupBulkDB(t)
cert, key := writeTestCertificate(t)
udp, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := udp.LocalAddr().(*net.UDPAddr).Port
_ = udp.Close()
clients := []model.Client{
{Email: "клиент@test", ID: uuid.NewString(), Password: "specific-password", Enable: true},
{Email: "user@test", ID: uuid.NewString(), Password: "user-password", Enable: true},
{Email: "fallback@test", ID: uuid.NewString(), Password: "fallback-password", Enable: true},
}
settings := map[string]any{"certificate": cert, "private_key": key, "clients": clients, "alpn": []string{"h3"}}
ib := mkInbound(t, port, model.TUIC, string(mustJSON(t, settings)))
ib.Listen = "127.0.0.1"
if err := database.GetDB().Save(ib).Error; err != nil {
t.Fatal(err)
}
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)
}
}
outbounds := []any{map[string]any{"tag": "blocked", "protocol": "blackhole"}}
for _, tag := range []string{"specific", "user", "fallback"} {
addr := startTuicRouteMarker(t, tag)
outbounds = append(outbounds, map[string]any{"tag": tag, "protocol": "freedom", "settings": map[string]any{"redirect": addr}})
}
apiPort := freeLoopbackPort(t)
template := map[string]any{
"api": map[string]any{"tag": "api", "services": []string{"HandlerService"}},
"inbounds": []any{map[string]any{"tag": "api", "listen": "127.0.0.1", "port": apiPort, "protocol": "tunnel", "settings": map[string]any{"address": "127.0.0.1"}}},
"log": map[string]any{"loglevel": "error", "access": "none"}, "stats": map[string]any{},
"outbounds": outbounds,
"policy": map[string]any{
"levels": map[string]any{
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "connIdle": 123},
"4294967295": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
},
"system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true},
},
"routing": map[string]any{"rules": []any{
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
map[string]any{"type": "field", "inboundTag": []string{ib.Tag}, "user": []string{clients[0].Email}, "outboundTag": "specific"},
map[string]any{"type": "field", "user": []string{clients[0].Email, clients[1].Email}, "outboundTag": "user"},
map[string]any{"type": "field", "inboundTag": []string{ib.Tag}, "outboundTag": "fallback"},
}},
}
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", string(mustJSON(t, template))); err != nil {
t.Fatal(err)
}
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
ordinaryPort := freeLoopbackPort(t)
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
Listen: mustJSON(t, "127.0.0.1"), Port: ordinaryPort, Protocol: "socks", Tag: "ordinary",
Settings: mustJSON(t, map[string]any{"auth": "password", "accounts": []any{map[string]any{"user": clients[1].Email, "pass": "ordinary-password"}}}),
})
var parsed conf.Config
if err := json.Unmarshal(mustJSON(t, cfg), &parsed); err != nil {
t.Fatal(err)
}
var relaySettings struct {
UserLevel uint32 `json:"userLevel"`
}
var relayInbound xray.InboundConfig
for _, candidate := range cfg.InboundConfigs {
if candidate.Tag == ib.Tag {
relayInbound = candidate
}
}
if err := json.Unmarshal(relayInbound.Settings, &relaySettings); err != nil {
t.Fatal(err)
}
relayPolicy := parsed.Policy.Levels[relaySettings.UserLevel]
if relaySettings.UserLevel == 0 || relaySettings.UserLevel == ^uint32(0) || relayPolicy == nil ||
relayPolicy.ConnectionIdle == nil || *relayPolicy.ConnectionIdle != 123 ||
relayPolicy.StatsUserUplink || relayPolicy.StatsUserDownlink || relayPolicy.StatsUserOnline {
t.Fatalf("relay policy lost session limits, collided with a custom level, or enabled duplicate stats: level=%d policy=%+v", relaySettings.UserLevel, relayPolicy)
}
if !parsed.Policy.Levels[0].StatsUserUplink || !parsed.Policy.Levels[^uint32(0)].StatsUserUplink {
t.Fatal("TUIC relay altered existing Xray user policy levels")
}
built, err := parsed.Build()
if err != nil {
t.Fatal(err)
}
x, err := core.New(built)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = x.Close() })
if err := x.Start(); err != nil {
t.Fatal(err)
}
if err := publishTuicRelayReadiness(cfg); err != nil {
t.Fatal(err)
}
counters := x.GetFeature(stats.ManagerType()).(stats.Manager)
instances, err := (&InboundService{}).DesiredTuicInstances()
if err != nil || len(instances) != 1 {
t.Fatalf("desired TUIC instances = %v, error %v", instances, err)
}
server, err := tuic.NewServer(instances[0], &tuic.SocksRelay{
Gate: tuic.ManagedRelayGate(tuic.SOCKSPortForInbound(ib.Id)), InboundID: ib.Id,
Addr: fmt.Sprintf("127.0.0.1:%d", tuic.SOCKSPortForInbound(ib.Id)), Password: tuic.SocksPassword(),
})
if err != nil {
t.Fatal(err)
}
if err := server.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.Close() })
for _, credentials := range []struct{ email, password string }{
{"unknown@test", tuic.SocksPassword()}, {clients[0].Email, "wrong-password"},
} {
relay := &tuic.SocksRelay{Addr: fmt.Sprintf("127.0.0.1:%d", tuic.SOCKSPortForInbound(ib.Id)), Password: credentials.password}
conn, err := relay.DialTCP(context.Background(), credentials.email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697})
if conn != nil {
_ = conn.Close()
}
if err == nil || err.Error() != "tuic socks: auth rejected (code 255)" {
t.Fatalf("invalid relay credentials were not rejected: %v", err)
}
}
for i, route := range []string{"specific", "user", "fallback"} {
t.Run(route, func(t *testing.T) {
client := connectTuicRoutingClient(t, instances[0], clients[i])
for _, mode := range []string{"tcp", "native", "quic"} {
t.Run(mode, func(t *testing.T) { probeTuicRoute(t, client, mode, route+":"+tuicRoutingProbe) })
}
up, down := int64(3*len(tuicRoutingProbe)), int64(3*len(route+":"+tuicRoutingProbe))
var gotUp, gotDown int64
deadline := time.Now().Add(3 * time.Second)
for gotUp < up || gotDown < down {
for _, delta := range server.CollectClientTraffic() {
if delta.Email != clients[i].Email {
t.Fatalf("unexpected native traffic identity: %+v", delta)
}
gotUp += delta.Up
gotDown += delta.Down
}
if time.Now().After(deadline) {
t.Fatalf("native traffic = %d/%d, want %d/%d", gotUp, gotDown, up, down)
}
time.Sleep(time.Millisecond)
}
if gotUp != up || gotDown != down {
t.Fatalf("native traffic = %d/%d, want %d/%d", gotUp, gotDown, up, down)
}
if _, _, err := (&InboundService{}).AddTraffic(nil, []*xray.ClientTraffic{{Email: clients[i].Email, Up: gotUp, Down: gotDown}}); err != nil {
t.Fatal(err)
}
var billed xray.ClientTraffic
if err := database.GetDB().Where("email = ?", clients[i].Email).First(&billed).Error; err != nil {
t.Fatal(err)
}
if billed.Up != up || billed.Down != down {
t.Fatalf("persisted TUIC usage = %d/%d, want %d/%d", billed.Up, billed.Down, up, down)
}
for _, direction := range []string{"uplink", "downlink"} {
if counter := counters.GetCounter("user>>>" + clients[i].Email + ">>>traffic>>>" + direction); counter != nil {
t.Fatalf("TUIC relay created duplicate Xray %s counter: %d", direction, counter.Value())
}
}
if online := counters.GetOnlineMap("user>>>" + clients[i].Email + ">>>online"); online != nil {
t.Fatal("TUIC relay exposed loopback addresses as user online IPs")
}
if counter := counters.GetCounter("inbound>>>" + ib.Tag + ">>>traffic>>>uplink"); counter == nil || counter.Value() < up {
t.Fatal("TUIC inbound totals were lost while disabling duplicate per-user counters")
}
})
}
for _, reset := range []string{"telegram", "scheduled"} {
t.Run("restore-"+reset, func(t *testing.T) {
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Update("enable", false).Error; err != nil {
t.Fatal(err)
}
server.UpdateUsers(nil)
var resetErr error
if reset == "telegram" {
resetErr = (&InboundService{}).ResetClientTrafficByEmail(clients[0].Email)
} else {
_, resetErr = (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id)
}
if resetErr != nil {
t.Fatal(resetErr)
}
renewed, err := (&InboundService{}).DesiredTuicInstances()
if err != nil || len(renewed) != 1 {
t.Fatalf("renewal missing: %+v %v", renewed, err)
}
server.UpdateUsers(renewed[0].Clients)
peer := connectTuicRoutingClient(t, renewed[0], clients[0])
for _, mode := range []string{"tcp", "native", "quic"} {
probeTuicRoute(t, peer, mode, "specific:"+tuicRoutingProbe)
}
})
}
t.Run("replacement-window", func(t *testing.T) { verifyTuicReplacementWindow(t, cfg, apiPort, ib, server, clients) })
verifyTuicHotUserUpdates(t, cfg, apiPort, ib, server, clients, counters)
priorOrdinary := int64(0)
if counter := counters.GetCounter("user>>>" + clients[1].Email + ">>>traffic>>>uplink"); counter != nil {
priorOrdinary = counter.Value()
}
relay := &tuic.SocksRelay{Addr: fmt.Sprintf("127.0.0.1:%d", ordinaryPort), Password: "ordinary-password"}
conn, err := relay.DialTCP(context.Background(), clients[1].Email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697})
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte(tuicRoutingProbe)); err != nil {
t.Fatal(err)
}
response := make([]byte, len("user:"+tuicRoutingProbe))
if _, err := io.ReadFull(conn, response); err != nil || string(response) != "user:"+tuicRoutingProbe {
t.Fatalf("ordinary SOCKS route = %q, error %v", response, err)
}
counter := counters.GetCounter("user>>>" + clients[1].Email + ">>>traffic>>>uplink")
if counter == nil || counter.Value()-priorOrdinary != int64(len(tuicRoutingProbe)) {
t.Fatal("ordinary Xray user traffic must still be counted independently of TUIC")
}
}
func startTuicRouteMarker(t *testing.T, marker string) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
packet, err := net.ListenPacket("udp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = packet.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
body := make([]byte, len(tuicRoutingProbe))
for {
if _, err := io.ReadFull(conn, body); err != nil {
return
}
if _, err := conn.Write(append([]byte(marker+":"), body...)); err != nil {
return
}
}
}()
}
}()
go func() {
buf := make([]byte, 2048)
for {
n, addr, err := packet.ReadFrom(buf)
if err != nil {
return
}
_, _ = packet.WriteTo(append([]byte(marker+":"), buf[:n]...), addr)
}
}()
return listener.Addr().String()
}
func verifyTuicHotUserUpdates(t *testing.T, initial *xray.Config, apiPort int, inbound *model.Inbound, server *tuic.Server, clients []model.Client, counters stats.Manager) {
t.Helper()
svc := &XrayService{}
if err := (&SettingService{}).SetRestartXrayOnClientDisable(true); err != nil {
t.Fatal(err)
}
api := &xray.XrayAPI{}
if err := api.Init(apiPort); err != nil {
t.Fatal(err)
}
defer api.Close()
ordinary := initial.InboundConfigs[len(initial.InboundConfigs)-1]
otherRelay := &tuic.SocksRelay{Addr: fmt.Sprintf("127.0.0.1:%d", ordinary.Port), Password: "ordinary-password"}
other, err := otherRelay.DialTCP(context.Background(), clients[1].Email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697})
if err != nil {
t.Fatal(err)
}
defer other.Close()
assertOtherSurvives := func() {
t.Helper()
_ = other.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := other.Write([]byte(tuicRoutingProbe)); err != nil {
t.Fatal(err)
}
response := make([]byte, len("user:"+tuicRoutingProbe))
if _, err := io.ReadFull(other, response); err != nil || string(response) != "user:"+tuicRoutingProbe {
t.Fatalf("TUIC account edit interrupted an unrelated live connection: %q, %v", response, err)
}
}
assertOtherSurvives()
instances, err := (&InboundService{}).DesiredTuicInstances()
if err != nil || len(instances) != 1 {
t.Fatalf("missing native instance: %+v, %v", instances, err)
}
persistent := connectTuicRoutingClient(t, instances[0], clients[1])
assertNativeSurvives := func() {
t.Helper()
for _, mode := range []string{"native", "quic"} {
probeTuicRoute(t, persistent, mode, "user:"+tuicRoutingProbe)
}
}
assertNativeSurvives()
t.Run("hot-rollback", func(t *testing.T) {
var previous xray.InboundConfig
for _, candidate := range initial.InboundConfigs {
if candidate.TuicRelay {
previous = candidate
}
}
invalid := previous
var settings map[string]any
if err := json.Unmarshal(previous.Settings, &settings); err != nil {
t.Fatal(err)
}
settings["accounts"] = []relayAccount{{User: strings.Repeat("x", 256), Pass: "secret"}}
invalid.Settings = mustJSON(t, settings)
err := applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &previous, Next: &invalid}})
if err == nil || !strings.Contains(err.Error(), "credentials exceed SOCKS5 bounds") {
t.Fatalf("invalid replacement was not refused: %v", err)
}
assertOtherSurvives()
assertNativeSurvives()
fresh := connectTuicRoutingClient(t, instances[0], clients[0])
for _, mode := range []string{"tcp", "native", "quic"} {
probeTuicRoute(t, fresh, mode, "specific:"+tuicRoutingProbe)
}
})
added := model.Client{Email: "добавлен@test", ID: uuid.NewString(), Password: "new-password", Enable: true}
before := initial
for _, step := range []string{"add", "delete", "rename", "empty"} {
t.Run("hot-"+step, func(t *testing.T) {
switch step {
case "add":
clients = append(clients, added)
case "delete":
clients = clients[1:]
case "rename":
added.Email = "переименован@test"
clients[len(clients)-1] = added
case "empty":
clients = nil
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
t.Fatal(err)
}
settings["clients"] = clients
if err := database.GetDB().Model(inbound).Update("settings", string(mustJSON(t, settings))).Error; err != nil {
t.Fatal(err)
}
after, err := svc.GetXrayConfig()
if err != nil {
t.Fatal(err)
}
after.InboundConfigs = append(after.InboundConfigs, ordinary)
diff, hot := xray.ComputeHotDiff(before, after)
if !hot || svc.restartToDropClients(diff) || applyTuicRelayReplacements(api, diff.TuicRelayChanges) != nil || !applyXrayHotDiff(api, diff) {
t.Fatalf("account update requires a core restart: hot=%t diff=%+v", hot, diff)
}
native, err := (&InboundService{}).DesiredTuicInstances()
if err != nil {
t.Fatal(err)
}
if len(native) > 0 {
server.UpdateUsers(native[0].Clients)
peer := connectTuicRoutingClient(t, native[0], added)
for _, mode := range []string{"tcp", "native", "quic"} {
probeTuicRoute(t, peer, mode, "fallback:"+tuicRoutingProbe)
}
if counter := counters.GetCounter("user>>>" + added.Email + ">>>traffic>>>uplink"); counter != nil {
t.Fatalf("hot-added user lost relay policy and was billed twice: %d", counter.Value())
}
} else {
server.UpdateUsers(nil)
}
if step != "empty" {
assertNativeSurvives()
}
assertOtherSurvives()
before = after
})
}
}
func connectTuicRoutingClient(t *testing.T, inst tuic.Instance, user model.Client) *quic.Conn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := quic.DialAddr(ctx, inst.BindTo(), &tls.Config{InsecureSkipVerify: true, NextProtos: inst.ALPN}, &quic.Config{EnableDatagrams: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = client.CloseWithError(0, "complete") })
id := uuid.MustParse(user.ID)
tlsState := client.ConnectionState().TLS
token, err := tlsState.ExportKeyingMaterial(string(id[:]), []byte(user.Password), 32)
if err != nil {
t.Fatal(err)
}
stream, err := client.OpenUniStreamSync(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := stream.Write(append(append([]byte{tuic.ProtocolVersion, tuic.CmdAuthenticate}, id[:]...), token...)); err != nil {
t.Fatal(err)
}
_ = stream.Close()
return client
}
func probeTuicRoute(t *testing.T, client *quic.Conn, mode, want string) {
t.Helper()
if err := exchangeTuicRoute(client, mode, want); err != nil {
t.Fatalf("%s relay request failed: %v", mode, err)
}
}
func exchangeTuicRoute(client *quic.Conn, mode, want string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
target := &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697}
var frame bytes.Buffer
var reader io.Reader
switch mode {
case "tcp":
stream, err := client.OpenStreamSync(ctx)
if err != nil {
return err
}
defer stream.Close()
_ = stream.SetDeadline(time.Now().Add(5 * time.Second))
frame.Write([]byte{tuic.ProtocolVersion, tuic.CmdConnect})
if err := tuic.WriteAddress(&frame, target); err != nil {
return err
}
frame.WriteString(tuicRoutingProbe)
if _, err := stream.Write(frame.Bytes()); err != nil {
return err
}
reader = stream
case "native", "quic":
association := uint16(1)
if mode == "quic" {
association = 2
}
if err := tuic.WritePacket(&frame, association, 1, 1, 0, target, []byte(tuicRoutingProbe)); err != nil {
return err
}
if mode == "native" {
if err := client.SendDatagram(frame.Bytes()); err != nil {
return err
}
response, err := client.ReceiveDatagram(ctx)
if err != nil {
return err
}
reader = bytes.NewReader(response)
} else {
stream, err := client.OpenUniStreamSync(ctx)
if err != nil {
return err
}
if _, err := stream.Write(frame.Bytes()); err != nil {
return err
}
_ = stream.Close()
response, err := client.AcceptUniStream(ctx)
if err != nil {
return err
}
_ = response.SetReadDeadline(time.Now().Add(5 * time.Second))
reader = response
}
_, cmd, err := tuic.ReadCommand(reader)
if err != nil {
return fmt.Errorf("read UDP response command: %w", err)
}
if cmd != tuic.CmdPacket {
return fmt.Errorf("UDP response command = %d, want Packet", cmd)
}
hdr, err := tuic.ReadPacketHeader(reader)
if err != nil {
return fmt.Errorf("read UDP response header: %w", err)
}
if hdr.AssocID != association || hdr.Size != uint16(len(want)) {
return fmt.Errorf("UDP response header = %+v, want association %d and size %d", hdr, association, len(want))
}
}
response := make([]byte, len(want))
if _, err := io.ReadFull(reader, response); err != nil {
return err
}
if string(response) != want {
return fmt.Errorf("%s route returned %q, want %q", mode, response, want)
}
return nil
}