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.
This commit is contained in:
Egor
2026-10-24 09:20:39 +05:00
committed by GitHub
parent 07910bf6c0
commit 207c705300
38 changed files with 3510 additions and 170 deletions
+25 -25
View File
@@ -17,8 +17,9 @@ import (
)
type LocalDeps struct {
APIPort func() int
SetNeedRestart func()
APIPort func() int
SetNeedRestart func()
RequestReconcile func()
}
type Local struct {
@@ -91,8 +92,8 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
return nil
}
err := tuic.GetManager().Ensure(inst)
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
if l.deps.RequestReconcile != nil {
l.deps.RequestReconcile()
}
return err
}
@@ -122,8 +123,8 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
}
if ib.Protocol == model.TUIC {
tuic.GetManager().Remove(ib.Id)
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
if l.deps.RequestReconcile != nil {
l.deps.RequestReconcile()
}
return nil
}
@@ -233,11 +234,12 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
}
func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
// Account changes also need the generated Xray SOCKS relay refreshed after commit.
if l.deps.RequestReconcile != nil {
l.deps.RequestReconcile()
}
if oldIb.Protocol == model.TUIC && newIb.Protocol != model.TUIC {
tuic.GetManager().Remove(oldIb.Id)
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
if !newIb.Enable {
return nil
}
@@ -245,25 +247,11 @@ func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbou
}
if oldIb.Protocol != model.TUIC {
_ = l.DelInbound(ctx, oldIb)
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
}
if oldIb.Protocol == model.TUIC && newIb.Protocol == model.TUIC && oldIb.Enable && newIb.Enable && oldIb.Tag != newIb.Tag && l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
if !newIb.Enable {
tuic.GetManager().Remove(newIb.Id)
if oldIb.Enable && l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return nil
}
if !oldIb.Enable && newIb.Enable {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
}
inst, ok := tuic.InstanceFromInbound(newIb)
if !ok {
tuic.GetManager().Remove(newIb.Id)
@@ -273,7 +261,13 @@ func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbou
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
if ib.Protocol == model.TUIC {
if l.deps.RequestReconcile != nil {
l.deps.RequestReconcile()
}
return nil
}
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
@@ -282,7 +276,13 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
if ib.Protocol == model.TUIC {
if l.deps.RequestReconcile != nil {
l.deps.RequestReconcile()
}
return nil
}
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
+31 -11
View File
@@ -9,7 +9,7 @@ import (
func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
resyncs := 0
local := NewLocal(LocalDeps{SetNeedRestart: func() { resyncs++ }})
local := NewLocal(LocalDeps{RequestReconcile: func() { resyncs++ }})
oldInbound := &model.Inbound{Id: 940001, Tag: "old-tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[]}`}
newInbound := *oldInbound
newInbound.Tag = "new-tuic-tag"
@@ -22,17 +22,37 @@ func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
}
}
func TestUpdateTuicInboundDoesNotResyncForProfileOnlyChange(t *testing.T) {
// applyLocalTuic reads the row after commit, so old/new payloads may already match.
func TestUpdateTuicInboundResyncsAccountsWithIdenticalPayloads(t *testing.T) {
resyncs := 0
local := NewLocal(LocalDeps{SetNeedRestart: func() { resyncs++ }})
oldInbound := &model.Inbound{Id: 940002, Tag: "tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[],"congestion_control":"bbr"}`}
newInbound := *oldInbound
newInbound.Settings = `{"clients":[],"congestion_control":"cubic","log_level":"debug"}`
if err := local.updateTuicInbound(context.Background(), oldInbound, &newInbound); err != nil {
t.Fatalf("updateTuicInbound: %v", err)
local := NewLocal(LocalDeps{RequestReconcile: func() { resyncs++ }})
inbound := &model.Inbound{Id: 940002, Tag: "tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[]}`}
if err := local.UpdateInbound(context.Background(), inbound, inbound); err != nil {
t.Fatal(err)
}
if resyncs != 0 {
t.Fatalf("profile-only update triggered %d Xray restarts, want 0", resyncs)
if resyncs != 1 {
t.Fatalf("relay account resync requests = %d, want 1", resyncs)
}
}
func TestTuicBulkUserOperationsResyncRelayAccounts(t *testing.T) {
for _, operation := range []string{"add", "remove"} {
t.Run(operation, func(t *testing.T) {
resyncs := 0
local := NewLocal(LocalDeps{RequestReconcile: func() { resyncs++ }})
inbound := &model.Inbound{Id: 940003, Protocol: model.TUIC, Enable: true}
var err error
if operation == "add" {
err = local.AddUser(context.Background(), inbound, map[string]any{"email": "bulk@tuic"})
} else {
err = local.RemoveUser(context.Background(), inbound, "bulk@tuic")
}
if err != nil {
t.Fatal(err)
}
if resyncs != 1 {
t.Fatalf("%s requested %d relay reconciliations, want 1", operation, resyncs)
}
})
}
}
+15
View File
@@ -19,6 +19,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -454,6 +455,9 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
}
case model.TUIC:
if err := tuic.ValidateRelayEmail(c.Email); err != nil {
return err
}
if c.ID == "" {
c.ID = uuid.NewString()
}
@@ -661,6 +665,17 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := validateClientEmail(updated.Email); err != nil {
return false, err
}
if emailErr := tuic.ValidateRelayEmail(updated.Email); emailErr != nil {
var attached int64
if err := database.GetDB().Model(&model.Inbound{}).
Joins("JOIN client_inbounds ci ON ci.inbound_id = inbounds.id").
Where("ci.client_id = ? AND inbounds.protocol = ?", id, model.TUIC).Count(&attached).Error; err != nil {
return false, err
}
if attached > 0 {
return false, emailErr
}
}
if err := validateClientSubID(updated.SubID); err != nil {
return false, err
}
@@ -368,6 +368,9 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
if err != nil {
return false, err
}
if err := validateTuicClientEmails(oldInbound.Protocol, clients); err != nil {
return false, err
}
existingClients, err := inboundSvc.GetClients(oldInbound)
if err != nil {
@@ -680,6 +683,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if err != nil {
return false, err
}
if err := validateTuicClientEmails(oldInbound.Protocol, clients); err != nil {
return false, err
}
oldClients, err := inboundSvc.GetClients(oldInbound)
if err != nil {
+5 -1
View File
@@ -1436,6 +1436,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
}
}
if err := validateTuicClientEmails(inbound.Protocol, clients); err != nil {
return inbound, false, err
}
needRestart := false
var postCommitApply func()
err = runSerializedTx(func(tx *gorm.DB) error {
@@ -1942,7 +1946,7 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
}
}
}
return nil
return validateTuicClientEmails(inbound.Protocol, clients)
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
@@ -66,9 +66,10 @@ func TestAddInboundTuicClientValidation(t *testing.T) {
}
}
func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
func TestTuicClientMutationsRequestRelayReconciliation(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
resyncs := 0
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, RequestReconcile: func() { resyncs++ }})
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
@@ -91,6 +92,7 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
t.Fatalf("AddInbound failed: %v", err)
}
resyncs = 0
addPayload := &model.Inbound{
Id: created.Id,
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"p2","email":"user2@tuic.com","enable":true}]}`,
@@ -102,6 +104,10 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
if needRestart {
t.Fatal("expected needRestart = false when adding TUIC client")
}
if resyncs == 0 {
t.Fatal("adding a TUIC client did not request an Xray relay account refresh")
}
resyncs = 0
updatePayload := &model.Inbound{
Id: created.Id,
@@ -114,6 +120,10 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
if needRestart {
t.Fatal("expected needRestart = false when updating TUIC client")
}
if resyncs == 0 {
t.Fatal("updating a TUIC client did not request an Xray relay account refresh")
}
resyncs = 0
needRestart, err = clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "user2@tuic.com", false, true)
if err != nil {
@@ -122,4 +132,7 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
if needRestart {
t.Fatal("expected needRestart = false when deleting TUIC client")
}
if resyncs == 0 {
t.Fatal("deleting a TUIC client did not request an Xray relay account refresh")
}
}
+62 -3
View File
@@ -18,12 +18,70 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
if err != nil {
return nil, err
}
if len(inbounds) == 0 {
return nil, nil
}
return s.desiredTuicInstances(inbounds)
}
func configuredTuicInstances(inbounds []*model.Inbound) []tuic.Instance {
instances := make([]tuic.Instance, 0)
for _, inbound := range inbounds {
if inbound.Protocol != model.TUIC || !inbound.Enable || inbound.NodeID != nil {
continue
}
inst, ok := tuic.ConfiguredInstanceFromInbound(inbound)
if !ok || tuic.ValidateClients(inst.Clients) != nil {
logger.Warningf("tuic: skipping inbound %d: invalid settings or clients", inbound.Id)
continue
}
inst.Clients = supportedTuicRelayClients(inst)
instances = append(instances, inst)
}
return instances
}
func supportedTuicRelayClients(inst tuic.Instance) []tuic.TuicClientSettings {
clients := make([]tuic.TuicClientSettings, 0, len(inst.Clients))
for _, client := range inst.Clients {
if tuic.ValidateRelayEmail(client.Email) == nil {
clients = append(clients, client)
}
}
if len(clients) != len(inst.Clients) {
logger.Warningf("tuic: inbound %d: %d clients excluded: email must contain 1 to 255 UTF-8 bytes", inst.Id, len(inst.Clients)-len(clients))
}
return clients
}
func validateTuicClientEmails(protocol model.Protocol, clients []model.Client) error {
if protocol != model.TUIC {
return nil
}
for _, client := range clients {
if err := tuic.ValidateRelayEmail(client.Email); err != nil {
return err
}
}
return nil
}
func (s *InboundService) desiredTuicInstances(inbounds []*model.Inbound) ([]tuic.Instance, error) {
db := database.GetDB()
reserved, err := templateInboundTags()
if err != nil {
return nil, err
}
instances := make([]tuic.Instance, 0, len(inbounds))
for _, ib := range inbounds {
if ib.Protocol != model.TUIC || !ib.Enable || ib.NodeID != nil {
continue
}
if _, ok := tuic.InstanceFromInbound(ib); !ok {
logger.Warningf("tuic: skipping inbound %d: invalid settings JSON", ib.Id)
continue
}
if _, taken := reserved[ib.Tag]; taken {
logger.Warningf("tuic: skipping inbound %d: tag conflicts with Xray template", ib.Id)
continue
}
built, err := s.buildInboundForLocalRuntime(db, ib)
if err != nil {
return nil, err
@@ -32,6 +90,7 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
if !ok {
continue
}
inst.Clients = supportedTuicRelayClients(inst)
instances = append(instances, inst)
}
emails := make([]string, 0)
+122 -6
View File
@@ -13,7 +13,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestInjectTuicSocks(t *testing.T) {
func TestInjectTuicRelay(t *testing.T) {
cfg := &xray.Config{}
inbounds := []*model.Inbound{
{
@@ -31,7 +31,13 @@ func TestInjectTuicSocks(t *testing.T) {
},
}
injectTuicSocks(cfg, inbounds)
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))
@@ -67,14 +73,21 @@ func TestInjectTuicSocks(t *testing.T) {
}
var parsedSettings struct {
Auth string `json:"auth"`
UDP bool `json:"udp"`
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 != "noauth" || !parsedSettings.UDP {
t.Fatalf("expected auth=noauth, udp=true, got %+v", parsedSettings)
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")
}
}
@@ -347,3 +360,106 @@ func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T
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
}
+35
View File
@@ -723,6 +723,13 @@ func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int)
}
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
reserved, err := templateInboundTags()
if err != nil {
return false, err
}
if _, exists := reserved[tag]; exists {
return true, nil
}
db := database.GetDB()
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
if ignoreId > 0 {
@@ -734,3 +741,31 @@ func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
}
return count > 0, nil
}
func templateInboundTags() (map[string]struct{}, error) {
raw, err := (&SettingService{}).GetXrayConfigTemplate()
if err != nil {
return nil, err
}
var cfg struct {
Inbounds []struct {
Tag string `json:"tag"`
} `json:"inbounds"`
API struct {
Tag string `json:"tag"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, err
}
tags := map[string]struct{}{"api": {}}
if cfg.API.Tag != "" {
tags[cfg.API.Tag] = struct{}{}
}
for _, inbound := range cfg.Inbounds {
if inbound.Tag != "" {
tags[inbound.Tag] = struct{}{}
}
}
return tags, nil
}
+421
View File
@@ -0,0 +1,421 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"sort"
"strconv"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type relayAccount struct {
User string `json:"user"`
Pass string `json:"pass"`
}
type tuicRelayUpdate struct {
update *tuic.RelayUpdate
previous, next *xray.InboundConfig
}
func finishTuicRelayUpdate(update *tuic.RelayUpdate, cfg *xray.InboundConfig) {
var owner int
var emails []string
if cfg != nil {
owner = cfg.TuicInboundID
var settings struct {
Accounts []relayAccount `json:"accounts"`
}
if json.Unmarshal(cfg.Settings, &settings) != nil {
update.Finish(owner, nil, false)
return
}
for _, account := range settings.Accounts {
emails = append(emails, account.User)
}
}
update.Finish(owner, emails, cfg != nil)
}
func invalidateTuicRelayReadiness(cfg *xray.Config) {
if cfg == nil {
return
}
for _, inbound := range cfg.InboundConfigs {
if inbound.TuicRelay {
if gate := tuic.ManagedRelayGate(inbound.Port); gate != nil {
gate.Invalidate(inbound.TuicInboundID)
}
}
}
}
func publishTuicRelayReadiness(cfg *xray.Config) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return publishTuicRelayBatch(ctx, cfg)
}
func publishTuicRelayBatch(ctx context.Context, cfg *xray.Config) error {
invalidateTuicRelayReadiness(cfg)
failures := make([]error, len(cfg.InboundConfigs))
slots := make(chan struct{}, 4)
var workers sync.WaitGroup
for i := range cfg.InboundConfigs {
inbound := &cfg.InboundConfigs[i]
if !inbound.TuicRelay {
continue
}
gate := tuic.ManagedRelayGate(inbound.Port)
if gate == nil {
continue
}
if err := validateTuicRelay(inbound); err != nil {
failures[i] = fmt.Errorf("TUIC relay %s not ready: %w", inbound.Tag, err)
continue
}
select {
case slots <- struct{}{}:
case <-ctx.Done():
failures[i] = ctx.Err()
continue
}
workers.Add(1)
go func(index int, inbound *xray.InboundConfig, gate *tuic.RelayGate) {
defer workers.Done()
defer func() { <-slots }()
failures[index] = publishTuicRelay(ctx, inbound, gate)
}(i, inbound, gate)
}
workers.Wait()
err := errors.Join(failures...)
if err != nil {
(&XrayService{}).SetToNeedReconcile()
}
return err
}
func publishTuicRelay(ctx context.Context, inbound *xray.InboundConfig, gate *tuic.RelayGate) error {
update, err := gate.BeginUpdate(ctx)
if err != nil {
return fmt.Errorf("publish TUIC relay %s readiness: %w", inbound.Tag, err)
}
// Starting the child process returns before its listeners have bound.
// Keep native sessions waiting until the configured accounts really work.
for {
err = verifyTuicRelayContext(ctx, nil, inbound)
if err == nil || ctx.Err() != nil {
break
}
select {
case <-time.After(20 * time.Millisecond):
case <-ctx.Done():
}
}
if err != nil {
update.Finish(inbound.TuicInboundID, nil, false)
return fmt.Errorf("TUIC relay %s not ready: %w", inbound.Tag, err)
}
finishTuicRelayUpdate(update, inbound)
return nil
}
func unavailableTuicRepairs(cfg *xray.Config) []xray.InboundReplacement {
var repairs []xray.InboundReplacement
for i := range cfg.InboundConfigs {
inbound := &cfg.InboundConfigs[i]
if !inbound.TuicRelay {
continue
}
gate := tuic.ManagedRelayGate(inbound.Port)
if gate != nil && !gate.ReadyFor(inbound.TuicInboundID) {
repairs = append(repairs, xray.InboundReplacement{Previous: inbound, Next: inbound})
}
}
return repairs
}
func repairUnavailableTuicRelays(cfg *xray.Config, apiPort int) error {
repairs := unavailableTuicRepairs(cfg)
if len(repairs) == 0 {
return nil
}
api := &xray.XrayAPI{}
if err := api.Init(apiPort); err != nil {
(&XrayService{}).SetToNeedReconcile()
return err
}
defer api.Close()
err := applyTuicRelayReplacements(api, repairs)
if err != nil {
(&XrayService{}).SetToNeedReconcile()
}
return err
}
func applyTuicRelayReplacements(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
var failures error
for _, group := range groupTuicRelayChanges(changes) {
failures = errors.Join(failures, applyTuicRelayGroup(api, group))
}
return failures
}
func validateTuicRelay(inbound *xray.InboundConfig) error {
var settings struct {
Auth string `json:"auth"`
Accounts []relayAccount `json:"accounts"`
}
if err := json.Unmarshal(inbound.Settings, &settings); err != nil {
return err
}
if inbound.Protocol != "socks" || settings.Auth != "password" || string(inbound.Listen) != `"127.0.0.1"` {
return errors.New("TUIC relay must require authentication on loopback")
}
for _, account := range settings.Accounts {
if len(account.User) == 0 || len(account.User) > 255 || len(account.Pass) == 0 || len(account.Pass) > 255 {
return errors.New("TUIC relay credentials exceed SOCKS5 bounds")
}
}
return nil
}
type tuicRelayTransaction struct{ states map[int]*tuicRelayUpdate }
func (t *tuicRelayTransaction) finish() {
for _, state := range t.states {
if state.update != nil {
finishTuicRelayUpdate(state.update, state.next)
}
}
}
func (t *tuicRelayTransaction) rollback(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
reverse := make([]xray.InboundReplacement, 0, len(changes))
for i := len(changes) - 1; i >= 0; i-- {
reverse = append(reverse, xray.InboundReplacement{Previous: changes[i].Next, Next: changes[i].Previous})
}
err := replaceTuicRelaySet(api, reverse)
for _, state := range t.states {
state.next = state.previous
if err != nil {
state.next = nil
}
}
return err
}
func applyTuicRelayGroup(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
transaction, err := stageTuicRelayGroup(api, changes)
if transaction != nil {
transaction.finish()
}
return err
}
func stageTuicRelayGroup(api *xray.XrayAPI, changes []xray.InboundReplacement) (*tuicRelayTransaction, error) {
for _, change := range changes {
if change.Next != nil {
if err := validateTuicRelay(change.Next); err != nil {
return nil, fmt.Errorf("TUIC relay %s update: %w", change.Next.Tag, err)
}
}
}
states := make(map[int]*tuicRelayUpdate)
for _, change := range changes {
for _, cfg := range []*xray.InboundConfig{change.Previous, change.Next} {
if cfg != nil && states[cfg.Port] == nil {
states[cfg.Port] = &tuicRelayUpdate{}
}
}
if old := change.Previous; old != nil {
if states[old.Port].previous == nil {
states[old.Port].previous = old
}
states[old.Port].next = nil
}
}
for _, change := range changes {
if next := change.Next; next != nil {
states[next.Port].next = next
}
}
ports := make([]int, 0, len(states))
for port := range states {
ports = append(ports, port)
}
sort.Ints(ports)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, port := range ports {
gate := tuic.ManagedRelayGate(port)
if gate == nil {
continue
}
update, err := gate.BeginUpdate(ctx)
if err != nil {
for _, state := range states {
if state.update != nil {
state.update.Cancel()
}
}
return nil, fmt.Errorf("pause TUIC relay: %w", err)
}
states[port].update = update
}
transaction := &tuicRelayTransaction{states: states}
if err := replaceTuicRelaySet(api, changes); err != nil {
rollbackErr := transaction.rollback(api, changes)
return transaction, errors.Join(fmt.Errorf("TUIC relay update: %w", err), rollbackErr)
}
return transaction, nil
}
func replaceTuicRelaySet(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
removed := make(map[string]bool)
for _, change := range changes {
inbound := change.Previous
if inbound == nil {
inbound = change.Next
}
if inbound == nil || removed[inbound.Tag] {
continue
}
if err := api.DelInbound(inbound.Tag); err != nil && !xray.IsMissingHandlerErr(err) {
return err
}
removed[inbound.Tag] = true
}
for _, change := range changes {
if change.Next == nil {
continue
}
raw, err := json.Marshal(change.Next)
if err != nil {
return err
}
if err := api.AddInbound(raw); err != nil {
return err
}
if err := verifyTuicRelay(change.Previous, change.Next); err != nil {
return err
}
}
return nil
}
func verifyTuicRelay(previous, next *xray.InboundConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return verifyTuicRelayContext(ctx, previous, next)
}
func verifyTuicRelayContext(ctx context.Context, previous, next *xray.InboundConfig) error {
var settings struct {
Auth string `json:"auth"`
Accounts []relayAccount `json:"accounts"`
}
if err := json.Unmarshal(next.Settings, &settings); err != nil {
return err
}
if settings.Auth != "password" || string(next.Listen) != `"127.0.0.1"` {
return errors.New("TUIC relay must require authentication on loopback")
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(next.Port))
wanted := make(map[string]string, len(settings.Accounts))
for _, account := range settings.Accounts {
wanted[account.User] = account.Pass
}
unknown := "_tuic_probe_" + strconv.FormatInt(time.Now().UnixNano(), 10)
for wanted[unknown] != "" {
unknown += "_"
}
accepted, err := probeRelayAccount(ctx, addr, relayAccount{User: unknown, Pass: "invalid"})
if err != nil {
return err
}
if accepted {
return errors.New("TUIC relay accepted an unknown account")
}
for _, account := range settings.Accounts {
accepted, err := probeRelayAccount(ctx, addr, account)
if err != nil {
return err
}
if !accepted {
return errors.New("TUIC relay did not install every configured account")
}
}
if previous != nil {
var old struct {
Accounts []relayAccount `json:"accounts"`
}
if err := json.Unmarshal(previous.Settings, &old); err != nil {
return err
}
for _, account := range old.Accounts {
// A rejected replacement can contain credentials SOCKS5 cannot encode.
// Such accounts cannot authenticate and need no removal probe on rollback.
if len(account.User) == 0 || len(account.User) > 255 || len(account.Pass) == 0 || len(account.Pass) > 255 {
continue
}
if password, exists := wanted[account.User]; exists && password == account.Pass {
continue
}
accepted, err := probeRelayAccount(ctx, addr, account)
if err != nil {
return err
}
if accepted {
return errors.New("TUIC relay retained a removed account")
}
}
}
return nil
}
func probeRelayAccount(ctx context.Context, addr string, account relayAccount) (bool, error) {
if len(account.User) == 0 || len(account.User) > 255 || len(account.Pass) == 0 || len(account.Pass) > 255 {
return false, errors.New("TUIC relay credentials exceed SOCKS5 bounds")
}
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
if err != nil {
return false, err
}
// Probes carry no relayed data; abortive close avoids TIME_WAIT per account.
_ = conn.(*net.TCPConn).SetLinger(0)
defer conn.Close()
deadline, _ := ctx.Deadline()
_ = conn.SetDeadline(deadline)
if _, err := conn.Write([]byte{5, 1, 2}); err != nil {
return false, err
}
var response [2]byte
if _, err := io.ReadFull(conn, response[:]); err != nil {
return false, err
}
if response != [2]byte{5, 2} {
return false, errors.New("TUIC relay did not require password authentication")
}
request := append([]byte{1, byte(len(account.User))}, account.User...)
request = append(request, byte(len(account.Pass)))
request = append(request, account.Pass...)
if _, err := conn.Write(request); err != nil {
return false, err
}
if _, err := io.ReadFull(conn, response[:]); err != nil {
return false, err
}
if response[0] != 1 {
return false, errors.New("TUIC relay returned an invalid authentication response")
}
return response[1] == 0, nil
}
@@ -0,0 +1,113 @@
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestTuicClientAddRejectsOversizeEmailBeforeWriting(t *testing.T) {
for _, email := range []string{strings.Repeat("a", 256), strings.Repeat("я", 128)} {
t.Run(email[:2], func(t *testing.T) {
setupBulkDB(t)
inbound := mkInbound(t, 45421, model.TUIC, `{"clients":[]}`)
client := model.Client{Email: email, ID: uuid.NewString(), Password: "secret", Enable: true}
payload := &model.Inbound{Id: inbound.Id, Settings: clientsSettings(t, []model.Client{client})}
_, err := (&ClientService{}).AddInboundClient(&InboundService{}, payload)
if err == nil || !strings.Contains(err.Error(), "255") {
t.Fatalf("oversized TUIC email was not rejected at save: %v", err)
}
var count int64
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", email).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("rejected client was persisted: count=%d err=%v", count, err)
}
})
}
}
func TestTuicLegacyOversizeEmailDoesNotDisableItsHealthySibling(t *testing.T) {
setupBulkDB(t)
clients := []model.Client{
{Email: strings.Repeat("x", 256), ID: uuid.NewString(), Password: "long", Enable: true},
{Email: "healthy@test", ID: uuid.NewString(), Password: "short", Enable: true},
}
inbound := mkInbound(t, 45422, model.TUIC, clientsSettings(t, clients))
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
var accounts []relayAccount
for _, relay := range cfg.InboundConfigs {
if relay.TuicInboundID != inbound.Id {
continue
}
var settings struct {
Accounts []relayAccount `json:"accounts"`
}
if err := json.Unmarshal(relay.Settings, &settings); err != nil {
t.Fatal(err)
}
accounts = settings.Accounts
}
if len(accounts) != 1 || accounts[0].User != "healthy@test" {
t.Fatalf("legacy invalid account contaminated the relay: got %d accounts", len(accounts))
}
instances, err := (&InboundService{}).DesiredTuicInstances()
if err != nil || len(instances) != 1 || len(instances[0].Clients) != 1 || instances[0].Clients[0].Email != "healthy@test" {
t.Fatalf("native admission disagrees with relay accounts: %+v err=%v", instances, err)
}
}
func TestTuicSharedRenameCannotBypassEmailLimitWithInboundFilter(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
client := model.Client{Email: "shared@test", ID: uuid.NewString(), Password: "secret", SubID: "shared-sub", Enable: true}
tuicInbound := mkInbound(t, 45423, model.TUIC, clientsSettings(t, []model.Client{client}))
vless := mkInbound(t, 45424, model.VLESS, clientsSettings(t, []model.Client{client}))
for _, inbound := range []*model.Inbound{tuicInbound, vless} {
if err := svc.SyncInbound(nil, inbound.Id, []model.Client{client}); err != nil {
t.Fatal(err)
}
}
stored := lookupClientRecord(t, client.Email)
updated := client
updated.Email = strings.Repeat("я", 128)
_, err := svc.Update(&InboundService{}, stored.Id, updated, 0, vless.Id)
if err == nil || !strings.Contains(err.Error(), "255") {
t.Fatalf("filtered VLESS edit bypassed the attached TUIC email limit: %v", err)
}
var current model.ClientRecord
if err := database.GetDB().First(&current, stored.Id).Error; err != nil || current.Email != client.Email {
t.Fatalf("rejected shared rename mutated the identity: email=%q err=%v", current.Email, err)
}
}
func TestTuicEmailBoundaryAndOtherProtocolsRemainUsable(t *testing.T) {
for _, tc := range []struct {
protocol model.Protocol
email string
}{
{model.TUIC, strings.Repeat("a", 255)},
{model.TUIC, strings.Repeat("я", 127) + "a"},
{model.VLESS, strings.Repeat("a", 256)},
} {
t.Run(string(tc.protocol)+"-"+tc.email[:2], func(t *testing.T) {
setupBulkDB(t)
inbound := mkInbound(t, 45425, tc.protocol, `{"clients":[]}`)
client := model.Client{Email: tc.email, ID: uuid.NewString(), Password: "secret", Enable: true}
_, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{Id: inbound.Id, Settings: clientsSettings(t, []model.Client{client})})
if err != nil {
t.Fatal(err)
}
stored := lookupClientRecord(t, tc.email)
if stored.Email != tc.email || stored.UUID != client.ID {
t.Fatal("accepted client identity was not preserved")
}
})
}
}
@@ -0,0 +1,346 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/infra/conf"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func isolationRelay(t *testing.T, id int, email string) xray.InboundConfig {
t.Helper()
return xray.InboundConfig{
Tag: fmt.Sprintf("isolation-%d", id), Protocol: "socks", Listen: mustJSON(t, "127.0.0.1"),
Port: tuic.SOCKSPortForInbound(id), TuicInboundID: id, TuicRelay: true,
Settings: mustJSON(t, map[string]any{
"auth": "password", "udp": true,
"accounts": []relayAccount{{User: email, Pass: tuic.SocksPassword()}},
}),
}
}
func isolationConfig(t *testing.T, relays ...xray.InboundConfig) *xray.Config {
t.Helper()
cfg := (balancerTestConfig{outbounds: allBalancerTestOutbounds}).build(t, freeLoopbackPort(t))
cfg.InboundConfigs = append(cfg.InboundConfigs, relays...)
return cfg
}
func startIsolationCore(t *testing.T, cfg *xray.Config) *xray.XrayAPI {
t.Helper()
var config conf.Config
if err := json.Unmarshal(mustJSON(t, cfg), &config); err != nil {
t.Fatal(err)
}
built, err := config.Build()
if err != nil {
t.Fatal(err)
}
instance, err := core.New(built)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = instance.Close() })
if err := instance.Start(); err != nil {
t.Fatal(err)
}
api := &xray.XrayAPI{}
if err := api.Init(cfg.InboundConfigs[0].Port); err != nil {
t.Fatal(err)
}
t.Cleanup(api.Close)
return api
}
func assertIsolationRelay(t *testing.T, relay xray.InboundConfig, email string) {
t.Helper()
marker := startTuicRouteMarker(t, "ready")
_, port, _ := net.SplitHostPort(marker)
number, _ := strconv.Atoi(port)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
bridge := &tuic.SocksRelay{
InboundID: relay.TuicInboundID,
Addr: fmt.Sprintf("127.0.0.1:%d", relay.Port), Password: tuic.SocksPassword(),
}
if relay.TuicRelay {
bridge.Gate = tuic.ManagedRelayGate(relay.Port)
}
conn, err := bridge.DialTCP(ctx, email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: uint16(number)})
if err != nil {
t.Fatalf("healthy relay %s rejected its client: %v", relay.Tag, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(time.Second))
if _, err := conn.Write([]byte(tuicRoutingProbe)); err != nil {
t.Fatal(err)
}
response := make([]byte, len("ready:"+tuicRoutingProbe))
if _, err := io.ReadFull(conn, response); err != nil || string(response) != "ready:"+tuicRoutingProbe {
t.Fatalf("relay response = %q, err=%v", response, err)
}
}
func TestTuicReadinessFailureDoesNotStrandLaterRelays(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
bad := isolationRelay(t, 960181, strings.Repeat("x", 256))
good := isolationRelay(t, 960182, "new-after-restart@test")
cfg := isolationConfig(t, bad, good)
startIsolationCore(t, cfg)
for _, warm := range []bool{false, true} {
t.Run(fmt.Sprintf("warm=%t", warm), func(t *testing.T) {
gate := tuic.ManagedRelayGate(good.Port)
update, err := gate.BeginUpdate(context.Background())
if err != nil {
t.Fatal(err)
}
update.Finish(good.TuicInboundID, []string{"old-before-restart@test"}, warm)
err = publishTuicRelayReadiness(cfg)
if err == nil {
t.Fatalf("invalid relay error = %v", err)
}
assertIsolationRelay(t, good, "new-after-restart@test")
if !isNeedXrayReconcile.Load() {
t.Fatal("failed readiness check did not schedule reconciliation")
}
gate.Invalidate(good.TuicInboundID)
if err := repairUnavailableTuicRelays(cfg, cfg.InboundConfigs[0].Port); err == nil {
t.Fatal("repair hid the invalid relay")
}
assertIsolationRelay(t, good, "new-after-restart@test")
})
}
}
func TestTuicMissingRelayDoesNotDelayHealthyReadiness(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
missing, healthy := isolationRelay(t, 960189, "missing@test"), isolationRelay(t, 960190, "ready@test")
cfg := isolationConfig(t, healthy)
startIsolationCore(t, cfg)
wanted := *cfg
wanted.InboundConfigs = []xray.InboundConfig{cfg.InboundConfigs[0], missing, healthy}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
done := make(chan error, 1)
go func() { done <- publishTuicRelayBatch(ctx, &wanted) }()
defer func() { cancel(); <-done }()
assertIsolationRelay(t, healthy, "ready@test")
}
func TestTuicRestartInvalidatesOldAccountsEvenWhenVerificationCannotStart(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
healthy := isolationRelay(t, 960196, "new@test")
cfg := isolationConfig(t, healthy)
startIsolationCore(t, cfg)
gate := tuic.ManagedRelayGate(healthy.Port)
update, err := gate.BeginUpdate(context.Background())
if err != nil {
t.Fatal(err)
}
update.Finish(healthy.TuicInboundID, []string{"old@test"}, true)
wanted := *cfg
wanted.InboundConfigs = []xray.InboundConfig{cfg.InboundConfigs[0]}
for id := 960192; id <= 960195; id++ {
wanted.InboundConfigs = append(wanted.InboundConfigs, isolationRelay(t, id, "missing@test"))
}
wanted.InboundConfigs = append(wanted.InboundConfigs, healthy)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if err := publishTuicRelayBatch(ctx, &wanted); err == nil {
t.Fatal("missing relays were published as ready")
}
ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
bridge := &tuic.SocksRelay{
Gate: gate, InboundID: healthy.TuicInboundID,
Addr: fmt.Sprintf("127.0.0.1:%d", healthy.Port), Password: tuic.SocksPassword(),
}
conn, err := bridge.DialTCP(ctx, "old@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
if conn != nil {
_ = conn.Close()
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("stale account crossed the gate after restart: %v", err)
}
}
func TestTuicFailedSwapDoesNotBlockOtherRelayOrRouting(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
first, second := isolationRelay(t, 960183, "first@test"), isolationRelay(t, 960184, "second@test")
before := isolationConfig(t, first, second)
api := startIsolationCore(t, before)
if err := publishTuicRelayReadiness(before); err != nil {
t.Fatal(err)
}
after := *before
after.InboundConfigs = append([]xray.InboundConfig(nil), before.InboundConfigs...)
after.InboundConfigs[1] = isolationRelay(t, first.TuicInboundID, strings.Repeat("x", 256))
after.InboundConfigs[2] = isolationRelay(t, second.TuicInboundID, "updated@test")
var router map[string]any
if err := json.Unmarshal(before.RouterConfig, &router); err != nil {
t.Fatal(err)
}
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "port": "9999", "outboundTag": "blocked"})
after.RouterConfig = mustJSON(t, router)
process := xray.NewTestProcess(before, filepath.Join(t.TempDir(), "config.json"))
applied, err := (&XrayService{}).tryHotApply(process, &after)
if applied || err == nil || !strings.Contains(err.Error(), "SOCKS5 bounds") {
t.Fatalf("partial apply did not report its deferred relay: applied=%t err=%v", applied, err)
}
route, err := api.TestRoute(xray.RouteTestRequest{IP: "203.0.113.1", Port: 9999, Network: "tcp"})
if err != nil || route.OutboundTag != "blocked" {
t.Fatalf("failed TUIC relay prevented unrelated routing update: route=%+v err=%v", route, err)
}
assertIsolationRelay(t, first, "first@test")
assertIsolationRelay(t, second, "updated@test")
if !process.GetConfig().InboundConfigs[1].Equals(&first) || !process.GetConfig().InboundConfigs[2].Equals(&after.InboundConfigs[2]) || string(process.GetConfig().RouterConfig) != string(after.RouterConfig) {
t.Fatal("running snapshot lost successful updates or recorded the failed relay as applied")
}
}
func TestTuicRelayTagSwapKeepsBothOwnersReachable(t *testing.T) {
first, second := isolationRelay(t, 960185, "first@test"), isolationRelay(t, 960186, "second@test")
before := isolationConfig(t, first, second)
api := startIsolationCore(t, before)
if err := publishTuicRelayReadiness(before); err != nil {
t.Fatal(err)
}
after := *before
after.InboundConfigs = append([]xray.InboundConfig(nil), before.InboundConfigs...)
after.InboundConfigs[1].Tag, after.InboundConfigs[2].Tag = second.Tag, first.Tag
diff, hot := xray.ComputeHotDiff(before, &after)
if !hot {
t.Fatal("relay tag swap cannot be hot applied")
}
if err := applyTuicRelayReplacements(api, diff.TuicRelayChanges); err != nil {
t.Fatalf("tag swap collided with a listener in its own transaction: %v", err)
}
assertIsolationRelay(t, after.InboundConfigs[1], "first@test")
assertIsolationRelay(t, after.InboundConfigs[2], "second@test")
}
func TestTuicFailedRenameDefersDependentRoutingButAddsOtherInbound(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
previous := isolationRelay(t, 960187, "original@test")
second := isolationRelay(t, 960198, "old-second@test")
before := isolationConfig(t, previous, second)
api := startIsolationCore(t, before)
if err := publishTuicRelayReadiness(before); err != nil {
t.Fatal(err)
}
after := *before
next := isolationRelay(t, previous.TuicInboundID, strings.Repeat("x", 256))
next.Tag = "renamed-unavailable"
ordinary := isolationRelay(t, 960188, "other@test")
ordinary.TuicRelay = false
ordinary.TuicInboundID = 0
ordinary.Port = freeLoopbackPort(t)
ordinary.Settings = mustJSON(t, map[string]any{"auth": "noauth", "udp": true})
dependent := ordinary
dependent.Tag = "dependent-ordinary"
dependent.Port = freeLoopbackPort(t)
updatedSecond := isolationRelay(t, second.TuicInboundID, "new-second@test")
after.InboundConfigs = []xray.InboundConfig{before.InboundConfigs[0], next, updatedSecond, ordinary, dependent}
var policy map[string]any
if err := json.Unmarshal(before.RouterConfig, &policy); err != nil {
t.Fatal(err)
}
policy["rules"] = append(policy["rules"].([]any),
map[string]any{"type": "field", "inboundTag": []string{next.Tag}, "outboundTag": "blocked"},
map[string]any{"type": "field", "inboundTag": []string{dependent.Tag}, "outboundTag": "blocked"},
map[string]any{"type": "field", "inboundTag": []string{second.Tag}, "outboundTag": "blocked"},
)
after.RouterConfig = mustJSON(t, policy)
process := xray.NewTestProcess(before, filepath.Join(t.TempDir(), "config.json"))
if applied, err := (&XrayService{}).tryHotApply(process, &after); applied || err == nil || !strings.Contains(err.Error(), "SOCKS5 bounds") {
t.Fatalf("failed rename result = %t, %v", applied, err)
}
assertIsolationRelay(t, previous, "original@test")
assertIsolationRelay(t, second, "old-second@test")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
secondBridge := &tuic.SocksRelay{
Gate: tuic.ManagedRelayGate(second.Port), InboundID: second.TuicInboundID,
Addr: fmt.Sprintf("127.0.0.1:%d", second.Port), Password: tuic.SocksPassword(),
}
pending, err := secondBridge.DialTCP(ctx, "new-second@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
if pending != nil {
_ = pending.Close()
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("new TUIC account escaped its deferred routing policy: %v", err)
}
assertIsolationRelay(t, ordinary, "other@test")
reservation, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", dependent.Port))
if err != nil {
t.Fatalf("new inbound started before its dependent routing was applied: %v", err)
}
_ = reservation.Close()
route, err := api.TestRoute(xray.RouteTestRequest{InboundTag: next.Tag, IP: "203.0.113.1", Port: 6697})
if err != nil || route.Matched {
t.Fatalf("routing depending on an uninstalled relay was published: route=%+v err=%v", route, err)
}
if string(process.GetConfig().RouterConfig) != string(before.RouterConfig) {
t.Fatal("snapshot recorded deferred routing as applied")
}
after.InboundConfigs[1] = previous
after.InboundConfigs[1].Tag = next.Tag
if applied, err := (&XrayService{}).tryHotApply(process, &after); !applied || err != nil {
t.Fatalf("corrected rename did not converge: applied=%t err=%v", applied, err)
}
route, err = api.TestRoute(xray.RouteTestRequest{InboundTag: next.Tag, IP: "203.0.113.1", Port: 6697})
if err != nil || route.OutboundTag != "blocked" || !process.GetConfig().Equals(&after) {
t.Fatalf("deferred routing did not converge after repair: route=%+v err=%v", route, err)
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", dependent.Port), time.Second)
if err != nil {
t.Fatalf("dependent inbound did not converge after routing recovery: %v", err)
}
_ = conn.Close()
}
func TestTuicFailedRenameStillAppliesUnrelatedRouting(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
previous := isolationRelay(t, 960191, "original@test")
before := isolationConfig(t, previous)
api := startIsolationCore(t, before)
if err := publishTuicRelayReadiness(before); err != nil {
t.Fatal(err)
}
after := *before
next := isolationRelay(t, previous.TuicInboundID, strings.Repeat("x", 256))
next.Tag = "renamed-failed"
after.InboundConfigs = []xray.InboundConfig{before.InboundConfigs[0], next}
var router map[string]any
if err := json.Unmarshal(before.RouterConfig, &router); err != nil {
t.Fatal(err)
}
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "port": "9999", "outboundTag": "blocked"})
after.RouterConfig = mustJSON(t, router)
process := xray.NewTestProcess(before, filepath.Join(t.TempDir(), "config.json"))
if _, err := (&XrayService{}).tryHotApply(process, &after); err == nil || !strings.Contains(err.Error(), "SOCKS5 bounds") {
t.Fatalf("failed rename was not reported: %v", err)
}
route, err := api.TestRoute(xray.RouteTestRequest{IP: "203.0.113.1", Port: 9999})
if err != nil || route.OutboundTag != "blocked" {
t.Fatalf("failed rename blocked an unrelated rule: route=%+v err=%v", route, err)
}
}
@@ -0,0 +1,179 @@
package service
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestTuicFailedReconcileRetriesWithoutRequestingCoreRestart(t *testing.T) {
setupBulkDB(t)
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", `{broken`); err != nil {
t.Fatal(err)
}
wasStopped := isManuallyStopped.Swap(false)
wasRestart := isNeedXrayRestart.Swap(false)
wasReconcile := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() {
isManuallyStopped.Store(wasStopped)
isNeedXrayRestart.Store(wasRestart)
isNeedXrayReconcile.Store(wasReconcile)
})
svc := &XrayService{}
rt := runtime.NewLocal(runtime.LocalDeps{SetNeedRestart: svc.SetToNeedRestart, RequestReconcile: svc.SetToNeedReconcile})
inbound := &model.Inbound{Id: 940004, Protocol: model.TUIC}
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
t.Fatal(err)
}
svc.ApplyPendingRestart()
if isNeedXrayRestart.Load() || !isNeedXrayReconcile.Load() {
t.Fatalf("failed TUIC reconciliation became a core restart or lost retry: restart=%t reconcile=%t", isNeedXrayRestart.Load(), isNeedXrayReconcile.Load())
}
}
func TestTuicQuotaResetDoesNotChangeRelayAdmission(t *testing.T) {
for _, reset := range []string{"telegram", "scheduled"} {
t.Run(reset, func(t *testing.T) {
setupBulkDB(t)
client := model.Client{Email: "renewed@test", ID: uuid.NewString(), Password: "secret", Enable: true}
ib := mkInbound(t, 45381, model.TUIC, clientsSettings(t, []model.Client{client}))
svc := &InboundService{}
if err := (&ClientService{}).SyncInbound(nil, ib.Id, []model.Client{client}); err != nil {
t.Fatal(err)
}
if err := svc.AddClientStat(database.GetDB(), ib.Id, &client); err != nil {
t.Fatal(err)
}
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", client.Email).Update("enable", false).Error; err != nil {
t.Fatal(err)
}
before, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
if reset == "telegram" {
err = svc.ResetClientTrafficByEmail(client.Email)
} else {
_, err = (&ClientService{}).ResetAllClientTraffics(svc, ib.Id)
}
if err != nil {
t.Fatal(err)
}
after, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatal(err)
}
if !before.Equals(after) {
t.Fatal("quota reset changed Xray relay admission; the renewed TUIC client needs an unrelated Xray reload")
}
instances, err := svc.DesiredTuicInstances()
if err != nil || len(instances) != 1 || len(instances[0].Clients) != 1 || instances[0].Clients[0].Email != client.Email {
t.Fatalf("renewed client missing from native admission: %+v, %v", instances, err)
}
})
}
}
func TestTuicClientChurnCanApplyWithoutRestartingOtherProtocols(t *testing.T) {
setupBulkDB(t)
clients := []model.Client{{Email: "first@test", ID: uuid.NewString(), Password: "secret", Enable: true}}
ib := mkInbound(t, 45382, model.TUIC, clientsSettings(t, clients))
svc := &XrayService{}
if err := (&SettingService{}).SetRestartXrayOnClientDisable(true); err != nil {
t.Fatal(err)
}
before, err := svc.GetXrayConfig()
if err != nil {
t.Fatal(err)
}
clients = append(clients, model.Client{Email: "новый@test", ID: uuid.NewString(), Password: "second", Enable: true})
for _, step := range []string{"add", "remove", "empty"} {
switch step {
case "remove":
clients = clients[1:]
case "empty":
clients = nil
}
if err := database.GetDB().Model(ib).Update("settings", clientsSettings(t, clients)).Error; err != nil {
t.Fatal(err)
}
after, err := svc.GetXrayConfig()
if err != nil {
t.Fatal(err)
}
diff, hot := xray.ComputeHotDiff(before, after)
if !hot || diff.Empty() || svc.restartToDropClients(diff) {
t.Fatalf("TUIC %s would restart unrelated protocols: hot=%t diff=%+v", step, hot, diff)
}
before = after
}
}
func TestBrokenTuicInboundDoesNotBlockOtherInbounds(t *testing.T) {
for _, fault := range []string{"invalid-settings", "invalid-shape", "template-tag"} {
t.Run(fault, func(t *testing.T) {
setupBulkDB(t)
ordinary := mkInbound(t, 45383, model.VLESS, `{"decryption":"none","clients":[]}`)
broken := mkInbound(t, 45384, model.TUIC, clientsSettings(t, []model.Client{{Email: "broken@test", ID: uuid.NewString(), Password: "secret", Enable: true}}))
switch fault {
case "invalid-settings":
broken.Settings = `{broken`
case "invalid-shape":
broken.Settings = `{"server":"broken"}`
case "template-tag":
broken.Tag = "template-control"
template := map[string]any{"inbounds": []any{map[string]any{"tag": broken.Tag, "listen": "127.0.0.1", "port": 45385, "protocol": "vless", "settings": map[string]any{"decryption": "none", "clients": []any{}}}}, "outbounds": []any{map[string]any{"protocol": "freedom", "tag": "direct"}}}
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", string(mustJSON(t, template))); err != nil {
t.Fatal(err)
}
}
if err := database.GetDB().Save(broken).Error; err != nil {
t.Fatal(err)
}
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatalf("one broken TUIC row blocked all protocols: %v", err)
}
count := 0
for _, inbound := range cfg.InboundConfigs {
if inbound.Tag == ordinary.Tag {
count++
if inbound.Protocol != "vless" || inbound.Port != ordinary.Port {
t.Fatalf("TUIC replaced the unrelated inbound: %+v", inbound)
}
}
}
if count != 1 {
t.Fatalf("unrelated inbound appears %d times, want once", count)
}
})
}
}
func TestTuicCannotSaveTemplateReservedTag(t *testing.T) {
setupBulkDB(t)
template := `{"api":{"tag":"management"},"inbounds":[{"tag":"template-control","listen":"127.0.0.1","port":45385,"protocol":"vless","settings":{"decryption":"none","clients":[]}}],"outbounds":[{"protocol":"freedom","tag":"direct"}]}`
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil {
t.Fatal(err)
}
for i, tag := range []string{"api", "management", "template-control"} {
inbound := &model.Inbound{Tag: tag, Protocol: model.TUIC, Listen: "127.0.0.1", Port: 45390 + i, Settings: `{"clients":[]}`}
saved, _, err := (&InboundService{}).AddInbound(inbound)
if err != nil {
t.Fatal(err)
}
var stored model.Inbound
if err := database.GetDB().First(&stored, saved.Id).Error; err != nil {
t.Fatal(err)
}
if stored.Tag == tag || stored.Tag != saved.Tag {
t.Fatalf("reserved tag %q was accepted or API returned a different tag: saved=%q stored=%q", tag, saved.Tag, stored.Tag)
}
}
}
@@ -0,0 +1,286 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// Renames and port swaps share one rollback boundary; independent inbounds do not.
func groupTuicRelayChanges(changes []xray.InboundReplacement) [][]xray.InboundReplacement {
parent := make([]int, len(changes))
for i := range parent {
parent[i] = i
}
root := func(i int) int {
for parent[i] != i {
i = parent[i]
}
return i
}
ports, owners := make(map[int]int), make(map[int]int)
tags := make(map[string]int)
for i, change := range changes {
for _, inbound := range []*xray.InboundConfig{change.Previous, change.Next} {
if inbound == nil {
continue
}
if j, ok := ports[inbound.Port]; ok {
parent[root(i)] = root(j)
}
ports[inbound.Port] = i
if j, ok := tags[inbound.Tag]; ok {
parent[root(i)] = root(j)
}
tags[inbound.Tag] = i
if inbound.TuicInboundID != 0 {
if j, ok := owners[inbound.TuicInboundID]; ok {
parent[root(i)] = root(j)
}
owners[inbound.TuicInboundID] = i
}
}
}
var groups [][]xray.InboundReplacement
indices := make(map[int]int)
for i, change := range changes {
key := root(i)
index, exists := indices[key]
if !exists {
index = len(groups)
indices[key] = index
groups = append(groups, nil)
}
groups[index] = append(groups[index], change)
}
return groups
}
func relayBindsOverlap(a, b xray.InboundConfig) bool {
return a.Tag == b.Tag || (a.Port > 0 && a.Port == b.Port &&
len(rawBindConflicts(&xray.Config{InboundConfigs: []xray.InboundConfig{a, b}})) > 0)
}
func tuicRequiresOrdinaryRemoval(old *xray.Config, changes []xray.InboundReplacement) bool {
for _, change := range changes {
if change.Next == nil {
continue
}
for _, inbound := range old.InboundConfigs {
if !inbound.TuicRelay && relayBindsOverlap(inbound, *change.Next) {
return true
}
}
}
return false
}
// A failed relay retains its last verified config. Dependent tag/port handoffs
// and their routing/outbounds wait too; unrelated changes can still converge.
func configWithDeferredTuic(old, desired *xray.Config, failed []xray.InboundReplacement) *xray.Config {
effective := *desired
previous, pendingTags := make(map[string]xray.InboundConfig), make(map[string]bool)
for _, change := range failed {
if change.Previous != nil {
previous[change.Previous.Tag] = *change.Previous
}
if change.Next != nil {
pendingTags[change.Next.Tag] = true
}
}
changedTags := false
dependentTags := make(map[string]bool)
for _, change := range failed {
if change.Previous == nil {
continue
}
old := change.Previous
nextTag := ""
for _, other := range failed {
if next := other.Next; next != nil && ((old.TuicInboundID != 0 && old.TuicInboundID == next.TuicInboundID) || (old.TuicInboundID == 0 && old.Port == next.Port)) {
nextTag = next.Tag
break
}
}
if nextTag != old.Tag {
changedTags = true
dependentTags[old.Tag] = true
if nextTag != "" {
dependentTags[nextTag] = true
}
}
}
effective.InboundConfigs = make([]xray.InboundConfig, 0, len(desired.InboundConfigs))
seen := make(map[string]bool)
for _, inbound := range desired.InboundConfigs {
if kept, ok := previous[inbound.Tag]; ok {
inbound = kept
} else if inbound.TuicRelay && pendingTags[inbound.Tag] {
continue
} else if !inbound.TuicRelay {
blocked := false
for _, kept := range previous {
if relayBindsOverlap(inbound, kept) {
blocked = true
break
}
}
if blocked {
index := slices.IndexFunc(old.InboundConfigs, func(candidate xray.InboundConfig) bool { return candidate.Tag == inbound.Tag })
if index < 0 {
continue
}
inbound = old.InboundConfigs[index]
changedTags = true
}
}
effective.InboundConfigs = append(effective.InboundConfigs, inbound)
seen[inbound.Tag] = true
}
for _, inbound := range old.InboundConfigs {
if _, ok := previous[inbound.Tag]; ok && !seen[inbound.Tag] {
effective.InboundConfigs = append(effective.InboundConfigs, inbound)
}
}
if changedTags && routingRulesChanged(old.RouterConfig, desired.RouterConfig, dependentTags, false) {
effective.RouterConfig = old.RouterConfig
effective.OutboundConfigs = old.OutboundConfigs
kept := effective.InboundConfigs[:0]
for _, inbound := range effective.InboundConfigs {
if !inbound.TuicRelay && routingRulesChanged(old.RouterConfig, desired.RouterConfig, map[string]bool{inbound.Tag: true}, true) {
index := slices.IndexFunc(old.InboundConfigs, func(candidate xray.InboundConfig) bool { return candidate.Tag == inbound.Tag })
if index < 0 {
continue
}
inbound = old.InboundConfigs[index]
}
kept = append(kept, inbound)
}
effective.InboundConfigs = kept
}
return &effective
}
func routingRulesChanged(old, desired []byte, tags map[string]bool, includeGlobal bool) bool {
matchingRules := func(raw []byte) ([]byte, error) {
var router struct {
Rules []map[string]any `json:"rules"`
}
if err := json.Unmarshal(raw, &router); err != nil {
return nil, err
}
var matched []map[string]any
for _, rule := range router.Rules {
inbounds, _ := rule["inboundTag"].([]any)
if includeGlobal && len(inbounds) == 0 {
matched = append(matched, rule)
continue
}
for _, inbound := range inbounds {
tag, _ := inbound.(string)
if tags[tag] {
matched = append(matched, rule)
break
}
}
}
return json.Marshal(matched)
}
before, err := matchingRules(old)
if err != nil {
return true
}
after, err := matchingRules(desired)
return err != nil || !bytes.Equal(before, after)
}
type stagedTuicRelay struct {
transaction *tuicRelayTransaction
changes []xray.InboundReplacement
changed bool
}
type tuicHotReconcile struct {
effective *xray.Config
staged []*stagedTuicRelay
}
func (r *tuicHotReconcile) finish() {
for _, group := range r.staged {
group.transaction.finish()
}
}
func (r *tuicHotReconcile) rollback(api *xray.XrayAPI) error {
var failures error
for _, group := range r.staged {
if group.changed {
failures = errors.Join(failures, group.transaction.rollback(api, group.changes))
group.changed = false
}
}
return failures
}
func reconcileTuicHotConfig(api *xray.XrayAPI, old, desired *xray.Config, changes []xray.InboundReplacement) (*tuicHotReconcile, error) {
result := &tuicHotReconcile{}
var failures error
var deferred []xray.InboundReplacement
for _, group := range groupTuicRelayChanges(changes) {
transaction, err := stageTuicRelayGroup(api, group)
if transaction != nil {
result.staged = append(result.staged, &stagedTuicRelay{transaction: transaction, changes: group, changed: err == nil})
}
if err != nil {
failures = errors.Join(failures, err)
deferred = append(deferred, group...)
}
}
effective := configWithDeferredTuic(old, desired, deferred)
if !bytes.Equal(effective.RouterConfig, desired.RouterConfig) {
for _, group := range result.staged {
if !group.changed {
continue
}
tags := make(map[string]bool)
for _, change := range group.changes {
for _, inbound := range []*xray.InboundConfig{change.Previous, change.Next} {
if inbound != nil {
tags[inbound.Tag] = true
}
}
}
if routingRulesChanged(old.RouterConfig, desired.RouterConfig, tags, true) {
failures = errors.Join(failures, errors.New("TUIC relay update waits for dependent routing"), group.transaction.rollback(api, group.changes))
group.changed = false
deferred = append(deferred, group.changes...)
}
}
}
result.effective = configWithDeferredTuic(old, desired, deferred)
// Repair unchanged relays too: a failed rollback or cold-start check does
// not necessarily change the desired JSON on the next reconciliation.
unchanged := *old
unchanged.InboundConfigs = nil
for _, inbound := range old.InboundConfigs {
changed := false
for _, change := range changes {
for _, candidate := range []*xray.InboundConfig{change.Previous, change.Next} {
if candidate != nil && (candidate.Port == inbound.Port || candidate.Tag == inbound.Tag) {
changed = true
}
}
}
if !changed {
unchanged.InboundConfigs = append(unchanged.InboundConfigs, inbound)
}
}
if err := applyTuicRelayReplacements(api, unavailableTuicRepairs(&unchanged)); err != nil {
failures = errors.Join(failures, fmt.Errorf("repair TUIC relay: %w", err))
}
return result, failures
}
@@ -0,0 +1,134 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/xtls/xray-core/app/proxyman/command"
routercommand "github.com/xtls/xray-core/app/router/command"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type delayedTuicRouting struct {
routercommand.UnimplementedRoutingServiceServer
client routercommand.RoutingServiceClient
entered, resume chan struct{}
reject atomic.Bool
}
func (s *delayedTuicRouting) AddRule(ctx context.Context, request *routercommand.AddRuleRequest) (*routercommand.AddRuleResponse, error) {
if s.reject.Load() {
return nil, status.Error(codes.Unavailable, "controlled routing failure")
}
close(s.entered)
select {
case <-s.resume:
return s.client.AddRule(ctx, request)
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestTuicNewAccountWaitsForItsRoutingPolicy(t *testing.T) {
previous := isolationRelay(t, 960197, "old@test")
before := isolationConfig(t, previous)
api := startIsolationCore(t, before)
if err := publishTuicRelayReadiness(before); err != nil {
t.Fatal(err)
}
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", before.InboundConfigs[0].Port), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := grpc.NewServer()
routing := &delayedTuicRouting{client: routercommand.NewRoutingServiceClient(conn), entered: make(chan struct{}), resume: make(chan struct{})}
ready := make(chan struct{})
close(ready)
command.RegisterHandlerServiceServer(server, &delayedTuicHandler{client: command.NewHandlerServiceClient(conn), entered: make(chan struct{}), resume: ready})
routercommand.RegisterRoutingServiceServer(server, routing)
go func() { _ = server.Serve(listener) }()
defer server.Stop()
// The proxy exposes the real core API while holding only the routing reload.
snapshot := *before
snapshot.InboundConfigs = append([]xray.InboundConfig(nil), before.InboundConfigs...)
snapshot.InboundConfigs[0].Port = listener.Addr().(*net.TCPAddr).Port
after := snapshot
after.InboundConfigs = append([]xray.InboundConfig(nil), snapshot.InboundConfigs...)
after.InboundConfigs[1] = isolationRelay(t, previous.TuicInboundID, "new@test")
var router map[string]any
if err := json.Unmarshal(before.RouterConfig, &router); err != nil {
t.Fatal(err)
}
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "user": []string{"new@test"}, "outboundTag": "blocked"})
after.RouterConfig = mustJSON(t, router)
process := xray.NewTestProcess(&snapshot, filepath.Join(t.TempDir(), "config.json"))
done := make(chan error, 1)
completed := make(chan struct{})
go func() { defer close(completed); _, err := (&XrayService{}).tryHotApply(process, &after); done <- err }()
var release sync.Once
resume := func() { release.Do(func() { close(routing.resume) }) }
defer func() { resume(); <-completed }()
select {
case <-routing.entered:
case <-time.After(3 * time.Second):
t.Fatal("routing reload was not reached")
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
bridge := &tuic.SocksRelay{Gate: tuic.ManagedRelayGate(previous.Port), InboundID: previous.TuicInboundID, Addr: fmt.Sprintf("127.0.0.1:%d", previous.Port), Password: tuic.SocksPassword()}
tunnel, err := bridge.DialTCP(ctx, "new@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
if tunnel != nil {
_ = tunnel.Close()
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("new account was admitted before its routing policy: %v", err)
}
resume()
if err := <-done; err != nil {
t.Fatal(err)
}
result, err := api.TestRoute(xray.RouteTestRequest{Email: "new@test", IP: "203.0.113.1", Port: 6697})
if err != nil || result.OutboundTag != "blocked" {
t.Fatalf("new routing policy not installed: result=%+v err=%v", result, err)
}
routing.reject.Store(true)
failed := after
failed.InboundConfigs = append([]xray.InboundConfig(nil), after.InboundConfigs...)
failed.InboundConfigs[1] = isolationRelay(t, previous.TuicInboundID, "failed@test")
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "user": []string{"failed@test"}, "outboundTag": "blocked"})
failed.RouterConfig = mustJSON(t, router)
if applied, err := (&XrayService{}).tryHotApply(process, &failed); applied || err != nil {
t.Fatalf("ordinary routing failure should request a restart fallback: applied=%t err=%v", applied, err)
}
if !process.GetConfig().Equals(&after) {
t.Fatal("failed routing reload advanced the running snapshot")
}
ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
tunnel, err = bridge.DialTCP(ctx, "failed@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
if tunnel != nil {
_ = tunnel.Close()
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("routing failure left the new relay account admitted: %v", err)
}
}
@@ -0,0 +1,183 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/xtls/xray-core/app/proxyman/command"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"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"
)
type delayedTuicHandler struct {
command.UnimplementedHandlerServiceServer
client command.HandlerServiceClient
entered chan struct{}
resume chan struct{}
once sync.Once
reject atomic.Bool
}
func (s *delayedTuicHandler) AddInbound(ctx context.Context, req *command.AddInboundRequest) (*command.AddInboundResponse, error) {
s.once.Do(func() { close(s.entered) })
select {
case <-s.resume:
if s.reject.Load() {
return nil, status.Error(codes.Unavailable, "controlled relay add failure")
}
return s.client.AddInbound(ctx, req)
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (s *delayedTuicHandler) RemoveInbound(ctx context.Context, req *command.RemoveInboundRequest) (*command.RemoveInboundResponse, error) {
return s.client.RemoveInbound(ctx, req)
}
func verifyTuicReplacementWindow(t *testing.T, cfg *xray.Config, apiPort int, inbound *model.Inbound, native *tuic.Server, clients []model.Client) {
t.Helper()
var previous xray.InboundConfig
for _, candidate := range cfg.InboundConfigs {
if candidate.Tag == inbound.Tag {
previous = candidate
}
}
next := previous
var settings map[string]any
if err := json.Unmarshal(previous.Settings, &settings); err != nil {
t.Fatal(err)
}
accounts := settings["accounts"].([]any)
settings["accounts"] = append(accounts, map[string]any{"user": "window@test", "pass": tuic.SocksPassword()})
next.Settings = mustJSON(t, settings)
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", apiPort), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
proxy := &delayedTuicHandler{client: command.NewHandlerServiceClient(conn), entered: make(chan struct{}), resume: make(chan struct{})}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
grpcServer := grpc.NewServer()
command.RegisterHandlerServiceServer(grpcServer, proxy)
go func() { _ = grpcServer.Serve(listener) }()
defer grpcServer.Stop()
var resumeOnce sync.Once
resume := func() { resumeOnce.Do(func() { close(proxy.resume) }) }
defer resume()
api := &xray.XrayAPI{}
if err := api.Init(listener.Addr().(*net.TCPAddr).Port); err != nil {
t.Fatal(err)
}
defer api.Close()
instances, err := (&InboundService{}).DesiredTuicInstances()
if err != nil || len(instances) != 1 {
t.Fatalf("native instance missing: %+v %v", instances, err)
}
newClient := model.Client{Email: "window@test", ID: uuid.NewString(), Password: "window-password", Enable: true}
native.UpdateUsers(append(append([]tuic.TuicClientSettings(nil), instances[0].Clients...), tuic.TuicClientSettings{
UUID: newClient.ID, Email: newClient.Email, Password: newClient.Password,
}))
defer native.UpdateUsers(instances[0].Clients)
newPeer := connectTuicRoutingClient(t, instances[0], newClient)
newUserResult := make(chan error, 1)
go func() { newUserResult <- exchangeTuicRoute(newPeer, "tcp", "fallback:"+tuicRoutingProbe) }()
select {
case err := <-newUserResult:
t.Fatalf("new account did not wait for relay configuration: %v", err)
case <-time.After(100 * time.Millisecond):
}
persistent := connectTuicRoutingClient(t, instances[0], clients[1])
probeTuicRoute(t, persistent, "native", "user:"+tuicRoutingProbe)
tcpClient := connectTuicRoutingClient(t, instances[0], clients[0])
streamClient := connectTuicRoutingClient(t, instances[0], clients[2])
updated := make(chan error, 1)
go func() {
updated <- applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &previous, Next: &next}})
}()
select {
case <-proxy.entered:
case err := <-updated:
t.Fatalf("replacement failed before the controlled window: %v", err)
case <-time.After(3 * time.Second):
t.Fatal("replacement did not remove the old listener")
}
tcpResult, udpResult := make(chan error, 1), make(chan error, 1)
go func() { tcpResult <- exchangeTuicRoute(tcpClient, "tcp", "specific:"+tuicRoutingProbe) }()
go func() { udpResult <- exchangeTuicRoute(streamClient, "quic", "fallback:"+tuicRoutingProbe) }()
var packet bytes.Buffer
target := &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697}
if err := tuic.WritePacket(&packet, 99, 1, 1, 0, target, []byte(tuicRoutingProbe)); err != nil {
t.Fatal(err)
}
if err := persistent.SendDatagram(packet.Bytes()); err != nil {
t.Fatal(err)
}
// A pending association must not block the same QUIC connection's existing UDP flow.
probeTuicRoute(t, persistent, "native", "user:"+tuicRoutingProbe)
resume()
if err := <-updated; err != nil {
t.Fatal(err)
}
for _, result := range []<-chan error{tcpResult, udpResult, newUserResult} {
if err := <-result; err != nil {
t.Errorf("new TUIC request failed during relay replacement: %v", err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
response, err := persistent.ReceiveDatagram(ctx)
if err != nil {
t.Fatalf("first packet of the queued native UDP association was lost: %v", err)
}
reader := bytes.NewReader(response)
_, _, err = tuic.ReadCommand(reader)
if err != nil {
t.Fatal(err)
}
header, err := tuic.ReadPacketHeader(reader)
body, bodyErr := io.ReadAll(reader)
if err != nil || bodyErr != nil || header.AssocID != 99 || string(body) != "user:"+tuicRoutingProbe {
t.Fatalf("queued UDP response mismatch: header=%+v body=%q errors=%v/%v", header, body, err, bodyErr)
}
if err := applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &next, Next: &previous}}); err != nil {
t.Fatal(err)
}
proxy.reject.Store(true)
err = applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &previous, Next: &next}})
if err == nil || strings.Count(err.Error(), "controlled relay add failure") != 2 {
t.Fatalf("relay update and rollback failures were hidden: %v", err)
}
recovery := make(chan error, 1)
go func() { recovery <- exchangeTuicRoute(tcpClient, "tcp", "specific:"+tuicRoutingProbe) }()
select {
case err := <-recovery:
t.Fatalf("failed rollback admitted a request into the missing relay: %v", err)
case <-time.After(50 * time.Millisecond):
}
if err := repairUnavailableTuicRelays(cfg, apiPort); err != nil {
t.Fatal(err)
}
if err := <-recovery; err != nil {
t.Fatalf("unavailable relay did not recover with unchanged desired config: %v", err)
}
}
@@ -0,0 +1,557 @@
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
}
+219 -58
View File
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"errors"
"fmt"
"path"
"path/filepath"
@@ -24,10 +25,11 @@ import (
)
var (
lock sync.Mutex
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
xrayState xrayLifecycle
lock sync.Mutex
isNeedXrayReconcile atomic.Bool
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
xrayState xrayLifecycle
)
type xrayLifecycle struct {
@@ -448,7 +450,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
// whatever rules the admin adds through the stock Routing page, exactly
// like routing any other protocol.
injectAmneziawgnetSocks(xrayConfig, inbounds)
injectTuicSocks(xrayConfig, inbounds)
tuicInstances := configuredTuicInstances(inbounds)
if err := injectTuicRelay(xrayConfig, tuicInstances); err != nil {
return nil, err
}
// Restores each opted-in peer's own distinct public IPv6 source identity
// for its outbound connections — a peer that has an IPv6 address in its
@@ -818,38 +823,121 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
}
}
const (
tuicEgressSocksSettings = `{"auth":"noauth","udp":true}`
// TUIC clients can connect to an IP while TLS carries a different or unresolvable SNI.
// Keep sniffed domains available for routing without replacing the requested destination.
tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"],"routeOnly":true}`
)
// Keep sniffed domains available for routing without replacing the requested destination.
const tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"],"routeOnly":true}`
func injectTuicSocks(cfg *xray.Config, inbounds []*model.Inbound) {
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
func injectTuicRelay(cfg *xray.Config, instances []tuic.Instance) error {
existingTags := map[string]struct{}{"api": {}}
var api struct {
Tag string `json:"tag"`
}
if len(cfg.API) > 0 {
if err := json.Unmarshal(cfg.API, &api); err != nil {
return err
}
if api.Tag != "" {
existingTags[api.Tag] = struct{}{}
}
}
for i := range cfg.InboundConfigs {
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
}
for _, inbound := range inbounds {
if inbound.Protocol != model.TUIC || !inbound.Enable || inbound.NodeID != nil {
var userLevel uint32
policyReady := false
for _, inst := range instances {
if _, taken := existingTags[inst.Tag]; taken {
logger.Warningf("tuic: skipping inbound %d: relay tag conflicts with another inbound", inst.Id)
continue
}
if _, taken := existingTags[inbound.Tag]; taken {
logger.Warning("tuic socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
continue
emails := make([]string, 0, len(inst.Clients))
for _, client := range inst.Clients {
if client.Email != "" {
emails = append(emails, client.Email)
}
}
existingTags[inbound.Tag] = struct{}{}
if !policyReady {
var err error
userLevel, err = tuicRelayPolicyLevel(cfg)
if err != nil {
return fmt.Errorf("tuic relay: policy: %w", err)
}
policyReady = true
}
slices.Sort(emails)
emails = slices.Compact(emails)
settings, err := tuic.SocksInboundSettings(emails, tuic.SocksPassword(), userLevel)
if err != nil {
return fmt.Errorf("tuic relay: inbound %d: %w", inst.Id, err)
}
existingTags[inst.Tag] = struct{}{}
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: tuic.SOCKSPortForInbound(inbound.Id),
Protocol: "socks",
Settings: json_util.RawMessage(tuicEgressSocksSettings),
Sniffing: json_util.RawMessage(tuicEgressSniffingSettings),
Tag: inbound.Tag,
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: tuic.SOCKSPortForInbound(inst.Id),
Protocol: "socks",
TuicRelay: true,
TuicInboundID: inst.Id,
Settings: json_util.RawMessage(settings),
Sniffing: json_util.RawMessage(tuicEgressSniffingSettings),
Tag: inst.Tag,
})
}
return nil
}
// Relay authentication carries routing identity; native TUIC owns client accounting and source IPs.
// Copy session limits from level 0, but disable Xray's duplicate traffic and loopback online stats.
func tuicRelayPolicyLevel(cfg *xray.Config) (uint32, error) {
var policy map[string]json.RawMessage
if len(cfg.Policy) > 0 {
if err := json.Unmarshal(cfg.Policy, &policy); err != nil {
return 0, err
}
}
if policy == nil {
policy = make(map[string]json.RawMessage)
}
var levels map[uint32]json.RawMessage
if raw, ok := policy["levels"]; ok {
if err := json.Unmarshal(raw, &levels); err != nil {
return 0, err
}
}
if levels == nil {
levels = make(map[uint32]json.RawMessage)
}
var relayPolicy map[string]json.RawMessage
if raw, ok := levels[0]; ok {
if err := json.Unmarshal(raw, &relayPolicy); err != nil {
return 0, err
}
}
if relayPolicy == nil {
relayPolicy = make(map[string]json.RawMessage)
}
for _, key := range []string{"statsUserUplink", "statsUserDownlink", "statsUserOnline"} {
relayPolicy[key] = json.RawMessage("false")
}
level := ^uint32(0)
for {
if _, exists := levels[level]; !exists {
break
}
if level == 0 {
return 0, errors.New("no free user policy level")
}
level--
}
raw, err := json.Marshal(relayPolicy)
if err != nil {
return 0, err
}
levels[level] = raw
policy["levels"], err = json.Marshal(levels)
if err != nil {
return 0, err
}
cfg.Policy, err = json.Marshal(policy)
return level, err
}
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
@@ -1404,7 +1492,7 @@ func (s *XrayService) RestartXray(isForce bool) error {
configUnchanged := process.GetConfig().Equals(xrayConfig)
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
logger.Debug("It does not need to restart Xray")
return nil
return repairUnavailableTuicRelays(xrayConfig, process.GetAPIPort())
}
// A config the core cannot bind never replaces one that works: its failed
// start exits the core, and the watchdog would then loop on it forever.
@@ -1418,10 +1506,18 @@ func (s *XrayService) RestartXray(isForce bool) error {
xrayState.holdBack(refused)
return fmt.Errorf("xray %s", refused)
}
if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
logger.Info("Xray config changes applied through the core API, no restart needed")
return nil
if !isForce && !configUnchanged {
applied, err := s.tryHotApply(process, xrayConfig)
if err != nil {
return err
}
if applied {
logger.Info("Xray config changes applied through the core API, no restart needed")
return nil
}
}
invalidateTuicRelayReadiness(process.GetConfig())
invalidateTuicRelayReadiness(xrayConfig)
_ = process.Stop()
} else if conflicts := bindConflicts(xrayConfig, nil); len(conflicts) > 0 {
// Nothing is running to protect and the core is the authority on what it
@@ -1429,6 +1525,7 @@ func (s *XrayService) RestartXray(isForce bool) error {
logger.Warning("xray config may not start:", conflicts[0].String())
}
invalidateTuicRelayReadiness(xrayConfig)
process = xray.NewProcess(xrayConfig)
xrayState.replace(process)
s.xrayAPI.StatsLastValues = nil
@@ -1437,6 +1534,10 @@ func (s *XrayService) RestartXray(isForce bool) error {
return err
}
if err := publishTuicRelayReadiness(xrayConfig); err != nil {
return err
}
return nil
}
@@ -1454,44 +1555,101 @@ func (s *XrayService) restartToDropClients(diff *xray.HotDiff) bool {
return restart
}
// tryHotApply attempts to reconcile the running Xray instance with newCfg
// through the core gRPC API (HandlerService for inbounds/outbounds,
// RoutingService for rules/balancers). It returns true when the running
// instance now matches newCfg; on any failure it returns false and the
// caller falls back to a full process restart, which cleans up whatever was
// partially applied. Callers must hold the package-level lock.
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bool {
// tryHotApply reconciles the running core with newCfg over gRPC; false means restart, while a TUIC
// relay error defers that update instead of restarting unrelated connections. Hold the package lock.
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) (applied bool, applyErr error) {
defer func() {
if applyErr != nil {
s.SetToNeedReconcile()
}
}()
oldCfg := process.GetConfig()
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
if !ok {
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
return false
return false, nil
}
if diff.Empty() {
if err := repairUnavailableTuicRelays(newCfg, process.GetAPIPort()); err != nil {
return false, err
}
process.SetConfig(newCfg)
persistHotConfig(process)
return true
return true, nil
}
// The core's RemoveUser drops the credential only, so a disabled or deleted
// client needs the restart this setting asks for.
if s.restartToDropClients(diff) {
logger.Info("hot apply: clients left the config, restarting to drop their live sessions")
return false
return false, nil
}
if tuicRequiresOrdinaryRemoval(oldCfg, diff.TuicRelayChanges) {
return false, nil
}
apiPort := process.GetAPIPort()
if apiPort <= 0 {
return false
if len(diff.TuicRelayChanges) > 0 {
return false, errors.New("TUIC relay update requires the Xray HandlerService API")
}
return false, nil
}
// A dedicated client: s.xrayAPI may be in use by traffic polling on other
// service instances and is reset around restarts.
hotAPI := xray.XrayAPI{}
if err := hotAPI.Init(apiPort); err != nil {
logger.Debug("hot apply: failed to init xray api:", err)
return false
if len(diff.TuicRelayChanges) > 0 {
return false, err
}
return false, nil
}
defer hotAPI.Close()
staged, relayErr := reconcileTuicHotConfig(&hotAPI, oldCfg, newCfg, diff.TuicRelayChanges)
defer staged.finish()
effective := staged.effective
intermediate := *oldCfg
intermediate.InboundConfigs = nil
for _, inbound := range oldCfg.InboundConfigs {
if !inbound.TuicRelay {
intermediate.InboundConfigs = append(intermediate.InboundConfigs, inbound)
}
}
for _, inbound := range effective.InboundConfigs {
if inbound.TuicRelay {
intermediate.InboundConfigs = append(intermediate.InboundConfigs, inbound)
}
}
ordinary, hot := xray.ComputeHotDiff(&intermediate, effective)
if !hot || len(ordinary.TuicRelayChanges) > 0 {
relayErr = errors.Join(relayErr, staged.rollback(&hotAPI))
intermediate.InboundConfigs = oldCfg.InboundConfigs
process.SetConfig(&intermediate)
persistHotConfig(process)
return false, errors.Join(relayErr, errors.New("independent Xray changes could not be planned after TUIC reconciliation"))
}
if !applyXrayHotDiff(&hotAPI, ordinary) {
relayErr = errors.Join(relayErr, staged.rollback(&hotAPI))
intermediate.InboundConfigs = oldCfg.InboundConfigs
process.SetConfig(&intermediate)
persistHotConfig(process)
if relayErr != nil {
return false, relayErr
}
return false, nil
}
balancerOverrides.reapply(process, &hotAPI, effective)
process.SetConfig(effective)
persistHotConfig(process)
if relayErr != nil {
return false, relayErr
}
return true, nil
}
func applyXrayHotDiff(hotAPI *xray.XrayAPI, diff *xray.HotDiff) bool {
// Removals first so changed handlers and port swaps never collide with
// the additions that follow.
for _, u := range diff.RemovedUsers {
@@ -1513,19 +1671,19 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo
}
}
for _, ob := range diff.AddedOutbounds {
if err := addOutboundReconciling(&hotAPI, ob); err != nil {
if err := addOutboundReconciling(hotAPI, ob); err != nil {
logger.Info("hot apply: add outbound failed:", err)
return false
}
}
for _, ib := range diff.AddedInbounds {
if err := addInboundReconciling(&hotAPI, ib); err != nil {
if err := addInboundReconciling(hotAPI, ib); err != nil {
logger.Info("hot apply: add inbound failed:", err)
return false
}
}
for _, u := range diff.AddedUsers {
if err := addUserReconciling(&hotAPI, u); err != nil {
if err := addUserReconciling(hotAPI, u); err != nil {
logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
return false
}
@@ -1536,11 +1694,6 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo
return false
}
}
// A routing reload rebuilds every balancer, and a removed outbound strands an override.
balancerOverrides.reapply(process, &hotAPI, newCfg)
process.SetConfig(newCfg)
persistHotConfig(process)
return true
}
@@ -1621,6 +1774,9 @@ func (s *XrayService) SetToNeedRestart() {
isNeedXrayRestart.Store(true)
}
// SetToNeedReconcile schedules config reconciliation without forcing an unchanged core to restart.
func (s *XrayService) SetToNeedReconcile() { isNeedXrayReconcile.Store(true) }
// GetXrayAPIPort returns the port the local xray process is listening on
// for its gRPC HandlerService, or 0 when xray isn't currently running.
// Exposed for the runtime package's LocalRuntime adapter without a
@@ -1638,17 +1794,22 @@ func (s *XrayService) IsNeedRestartAndSetFalse() bool {
return isNeedXrayRestart.CompareAndSwap(true, false)
}
// ApplyPendingRestart consumes the need-restart flag and restarts Xray. If the
// restart fails (for example GetXrayConfig hits a transient DB error and leaves
// the old process running), it re-arms the flag so the next tick retries instead
// of silently dropping the pending config change.
// ApplyPendingRestart re-arms the original flags when applying fails, so the next tick retries; a
// reconcile retry never becomes a restart, which would disconnect unrelated clients.
func (s *XrayService) ApplyPendingRestart() {
if !s.IsNeedRestartAndSetFalse() {
reconcile := isNeedXrayReconcile.Swap(false)
restart := s.IsNeedRestartAndSetFalse()
if !restart && !reconcile {
return
}
if err := s.RestartXray(false); err != nil {
logger.Error("restart xray failed:", err)
s.SetToNeedRestart()
logger.Error("reconcile xray failed:", err)
if restart {
s.SetToNeedRestart()
}
if reconcile {
s.SetToNeedReconcile()
}
}
}
@@ -171,7 +171,7 @@ func saveDuringTest(t *testing.T, process *xray.Process, cfg *xray.Config) {
t.Helper()
lock.Lock()
defer lock.Unlock()
if !(&XrayService{}).tryHotApply(process, cfg) {
if applied, err := (&XrayService{}).tryHotApply(process, cfg); err != nil || !applied {
t.Fatal("tryHotApply = false, want the change applied live")
}
}
@@ -186,7 +186,7 @@ func TestBalancerOverrideSurvivesRoutingHotApply(t *testing.T) {
}
newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort)
if !svc.tryHotApply(process, newCfg) {
if applied, err := svc.tryHotApply(process, newCfg); err != nil || !applied {
t.Fatal("tryHotApply = false, want the routing change applied live")
}
@@ -205,7 +205,7 @@ func TestBalancerOverrideClearedWhenTargetOutboundRemoved(t *testing.T) {
}
newCfg := balancerTestConfig{outbounds: []string{"direct", "blocked", "proxy-a"}}.build(t, apiPort)
if !svc.tryHotApply(process, newCfg) {
if applied, err := svc.tryHotApply(process, newCfg); err != nil || !applied {
t.Fatal("tryHotApply = false, want the outbound removal applied live")
}
@@ -444,7 +444,7 @@ func TestBalancerOverrideNotCarriedAcrossRestart(t *testing.T) {
restarted, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds})
newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort)
if !(&XrayService{}).tryHotApply(restarted, newCfg) {
if applied, err := (&XrayService{}).tryHotApply(restarted, newCfg); err != nil || !applied {
t.Fatal("tryHotApply = false, want the routing change applied live")
}
@@ -16,10 +16,33 @@ func TestTryHotApplyWritesConfigFile(t *testing.T) {
routing := json_util.RawMessage(`{"rules":[]}`)
process := xray.NewTestProcess(&xray.Config{RouterConfig: routing}, path)
if !(&XrayService{}).tryHotApply(process, &xray.Config{RouterConfig: routing}) {
if applied, err := (&XrayService{}).tryHotApply(process, &xray.Config{RouterConfig: routing}); err != nil || !applied {
t.Fatal("tryHotApply = false, want true for an unchanged config")
}
if data, err := os.ReadFile(path); err != nil || len(data) == 0 {
t.Fatalf("config.json not written after hot apply (err %v)", err)
}
}
func TestTuicChangeWithoutAPIDoesNotFallBackToCoreRestart(t *testing.T) {
wasPending := isNeedXrayReconcile.Swap(false)
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
previous := &xray.Config{InboundConfigs: []xray.InboundConfig{{
Tag: "tuic-test", Listen: json_util.RawMessage(`"127.0.0.1"`), Port: 64001,
Protocol: "socks", TuicRelay: true,
Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"old@test","pass":"secret"}]}`),
}}}
next := &xray.Config{InboundConfigs: append([]xray.InboundConfig(nil), previous.InboundConfigs...)}
next.InboundConfigs[0].Settings = json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"new@test","pass":"secret"}]}`)
process := xray.NewTestProcess(previous, filepath.Join(t.TempDir(), "config.json"))
applied, err := (&XrayService{}).tryHotApply(process, next)
if applied || err == nil || err.Error() != "TUIC relay update requires the Xray HandlerService API" {
t.Fatalf("missing API must defer TUIC changes, not fall back to restarting the core: applied=%t err=%v", applied, err)
}
if !process.GetConfig().Equals(previous) {
t.Fatal("failed relay update advanced the running config snapshot")
}
if !isNeedXrayReconcile.Load() {
t.Fatal("direct reconciliation failure did not schedule a retry")
}
}
+3 -2
View File
@@ -555,8 +555,9 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
// The closures bridge into XrayService (which owns the running xray
// process state) without forcing the runtime package to import service.
mgr := runtime.NewManager(runtime.LocalDeps{
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
RequestReconcile: func() { s.xrayService.SetToNeedReconcile() },
})
runtime.SetManager(mgr)
mgr.SetNodeEgressResolver(&s.settingService)