mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-05 05:32:07 +03:00
0054e671f8
* feat(tuic): implement native in-process Go TUIC v5 server - Implement native TUIC v5 protocol server on pure Go using quic-go - Bridge decrypted TCP/UDP traffic into Xray-core via loopback SOCKS5 inbound - Support full Xray routing rules (geosite/geoip) and cascading outbounds - Implement atomic per-client traffic accounting with TotalGB and ExpiryTime - Add automatic legacy cleanup for older Rust tuic-server binaries, configs, and orphaned processes - Eliminate external Rust tuic-server downloads from install/CI scripts * fix(tuic): address traffic accounting, client reload, and socket lifecycle issues * fix(service): update checkTuicSocksReverseConflict to use bindAddr for listenOverlaps * fix(tuic): resolve traffic double-accounting, UDP fragmentation, and socket lifecycle issues * feat(tuic): complete native Go integration and address audit findings - Integrate an isolated QUIC fork pinned to a specific commit - Preserve original QUIC dependencies for Xray, Hysteria and Gin - Apply BBR, CUBIC and Reno to server connections and exported client profiles - Bridge Xray BBR with correct monotonic time and congestion type conversions - Handle congestion sender recreation after PMTU changes - Update congestion control for new connections without restarting the listener - Preserve existing connections and their selected congestion controller - Apply per-inbound log levels through the shared panel logger - Add lifecycle, authentication and TCP/UDP relay events without exposing secrets - Rate-limit repeated authentication and relay warnings - Support native and QUIC UDP relay modes on the same listener - Recover UDP associations after relay worker failures - Fix TCP relay cancellation, idle shutdown and half-close handling - Close active sessions when client credentials are revoked or disabled - Track traffic by immutable client statistics IDs across email and UUID changes - Prevent ambiguous accounting and duplicate UUIDs within TUIC inbounds - Persist pending traffic in a durable shutdown journal - Replay journal batches transactionally without duplicate accounting - Report server shutdown failures through the shared logger - Preserve legacy flat and nested TUIC settings compatibility - Normalize congestion controller values consistently across backend and frontend - Preserve controller, UDP mode and SNI in client links and subscriptions - Separate client profile options from server settings in the TUIC form - Keep certificate path autofill explicit when changing client SNI - Align UDP packet size validation with protocol limits - Simplify and localize TUIC field hints and certificate autofill messages - Add controller, TCP/UDP, logging and live settings update tests - Add accounting identity, journal replay and shutdown regression tests - Add relay recovery, session revocation and legacy frontend form tests * fix(service): alias the TUIC duplicate-UUID subquery for PostgreSQL < 16 syncInboundClients runs a COUNT(*) FROM (subquery) for every client sync, whatever the protocol. PostgreSQL before 16 rejects a FROM subquery with no alias, so on the distro PostgreSQL install.sh provisions (14 on Ubuntu 22.04, 15 on Debian 12) every client add or edit failed with SQLSTATE 42601. Reproduced against postgres:15 with the new env-gated test. * fix(database): create tuic_traffic_receipts through the model migration AddTuicTrafficBatch issued CREATE TABLE IF NOT EXISTS at runtime, a schema change outside db.go. The table was invisible to allModels and migrationModels, so x-ui migrate-db dropped the receipts and a retained journal could be counted twice after a SQLite to PostgreSQL move. It is now a GORM model in both lists, and the insert uses OnConflict DoNothing. * chore(tuic): skip the ICMP-dependent relay test on Windows, drop dead collectors Go disables SIO_UDP_CONNRESET on Windows, so a dead UDP bridge never fails a read there and TestAudit3UDPAssociationMustRecoverAfterBridgeReadFailure was red on every Windows run. Server.CollectTotalTraffic and Manager.CollectTraffic had no caller. * refactor(tuic): serve TUIC on apernet/quic-go instead of a personal fork The native server depended on github.com/poise52/quic-go, a personal fork of apernet/quic-go patched only to pick the congestion controller before the handshake. That put a second QUIC/TLS stack in the binary that no upstream security fix reaches. apernet/quic-go is already in the graph through xray-core and exposes SetCongestionControl, so BBR is now installed on each accepted connection with Xray's own congestion.UseBBR; the cross-module BBR adapter is gone. apernet ships New Reno as its only built-in sender, so a cubic setting is served as new_reno server-side (clients still get cubic in their profile). The test inspectors now read the sender under congestionMutex, which the post-handshake install writes under. Linux loopback, single stream through Xray: 2428 -> 3383 Mbit/s (bbr). --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
332 lines
13 KiB
Go
332 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// TestAddClientTraffic_MatchesByEmail covers two scenarios that share one fix:
|
|
// client_traffics is keyed by email (one shared row per email no matter how many
|
|
// inbounds the client is attached to), so local traffic must be applied by email
|
|
// regardless of which inbound_id the row happens to carry.
|
|
//
|
|
// - staleEmail: the row points at an inbound id that no longer exists (a deleted
|
|
// earlier incarnation, AddClientStat's OnConflict-DoNothing never refreshes it).
|
|
// - dualEmail: the client is attached to both a node inbound and the mother inbound,
|
|
// but the node inbound was attached first, so the shared row carries the node
|
|
// inbound's id (issue #4921). The old `inbound_id NOT IN (node inbounds)` filter
|
|
// dropped this client's local traffic, leaving it stuck at zero and offline.
|
|
//
|
|
// Both must have their local traffic counted.
|
|
func TestAddClientTraffic_MatchesByEmail(t *testing.T) {
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
|
|
db := database.GetDB()
|
|
|
|
const staleEmail = "stale-user"
|
|
const dualEmail = "dual-user"
|
|
|
|
localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
|
|
if err := db.Create(localInbound).Error; err != nil {
|
|
t.Fatalf("create local inbound: %v", err)
|
|
}
|
|
nodeID := 1
|
|
nodeInbound := &model.Inbound{UserId: 1, Tag: "node-in", Enable: true, Port: 40002, Protocol: model.VLESS, NodeID: &nodeID}
|
|
if err := db.Create(nodeInbound).Error; err != nil {
|
|
t.Fatalf("create node inbound: %v", err)
|
|
}
|
|
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: staleEmail, Enable: true}).Error; err != nil {
|
|
t.Fatalf("create stale client_traffics: %v", err)
|
|
}
|
|
// Attached to both inbounds, but the node inbound won the OnConflict so the
|
|
// shared row is owned by the node inbound id.
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: dualEmail, Enable: true}).Error; err != nil {
|
|
t.Fatalf("create dual client_traffics: %v", err)
|
|
}
|
|
|
|
svc := InboundService{}
|
|
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
|
|
{Email: staleEmail, Up: 10, Down: 20},
|
|
{Email: dualEmail, Up: 30, Down: 40},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("addClientTraffic: %v", err)
|
|
}
|
|
|
|
var stale xray.ClientTraffic
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", staleEmail).First(&stale).Error; err != nil {
|
|
t.Fatalf("reload stale row: %v", err)
|
|
}
|
|
if stale.Up != 10 || stale.Down != 20 {
|
|
t.Errorf("stale-pointer row not updated: up=%d down=%d, want 10/20", stale.Up, stale.Down)
|
|
}
|
|
if stale.LastOnline == 0 {
|
|
t.Errorf("stale-pointer row LastOnline not set")
|
|
}
|
|
|
|
var dual xray.ClientTraffic
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", dualEmail).First(&dual).Error; err != nil {
|
|
t.Fatalf("reload dual row: %v", err)
|
|
}
|
|
if dual.Up != 30 || dual.Down != 40 {
|
|
t.Errorf("node-owned row not updated by local traffic (issue #4921): up=%d down=%d, want 30/40", dual.Up, dual.Down)
|
|
}
|
|
if dual.LastOnline == 0 {
|
|
t.Errorf("node-owned row LastOnline not set (client stayed offline)")
|
|
}
|
|
}
|
|
|
|
// TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId covers "Start After
|
|
// First Use": a delayed-start client carries a negative expiry (the duration) that
|
|
// must convert to an absolute deadline on its first traffic tick. When the client's
|
|
// email-keyed client_traffics row still points at a deleted inbound (stale inbound_id
|
|
// after an inbound delete+recreate), the conversion used to resolve no inbound and
|
|
// silently skip, leaving the client perpetually "not started". The fix resolves the
|
|
// owning inbound via the client_inbounds link instead.
|
|
func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(t *testing.T) {
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
|
|
db := database.GetDB()
|
|
|
|
const email = "delayed-user"
|
|
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
|
|
const sevenDays = int64(7 * 86400000)
|
|
|
|
client := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, ExpiryTime: -sevenDays}
|
|
inbound := &model.Inbound{
|
|
Tag: "vless-delayed", Enable: true, Port: 45001, Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp","security":"reality"}`,
|
|
Settings: clientsSettings(t, []model.Client{client}),
|
|
}
|
|
if err := db.Create(inbound).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
|
|
svc := InboundService{}
|
|
if err := svc.clientService.SyncInbound(db, inbound.Id, []model.Client{client}); err != nil {
|
|
t.Fatalf("SyncInbound: %v", err)
|
|
}
|
|
|
|
// The email-keyed traffic row survives an inbound delete+recreate pointing at a
|
|
// dead inbound id; client_inbounds still links the client to the live inbound.
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: email, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
|
|
t.Fatalf("create stale traffic row: %v", err)
|
|
}
|
|
|
|
before := time.Now().UnixMilli()
|
|
if err := svc.addClientTraffic(db, []*xray.ClientTraffic{{Email: email, Up: 100, Down: 200}}); err != nil {
|
|
t.Fatalf("addClientTraffic: %v", err)
|
|
}
|
|
|
|
var row xray.ClientTraffic
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
|
|
t.Fatalf("reload traffic row: %v", err)
|
|
}
|
|
if row.ExpiryTime <= 0 {
|
|
t.Fatalf("delayed-start expiry not converted: still %d (stale inbound_id skipped the conversion)", row.ExpiryTime)
|
|
}
|
|
if row.ExpiryTime < before+sevenDays-5000 || row.ExpiryTime > before+sevenDays+5000 {
|
|
t.Errorf("converted expiry = %d, want ~now+7d (%d)", row.ExpiryTime, before+sevenDays)
|
|
}
|
|
|
|
reloaded, err := svc.GetInbound(inbound.Id)
|
|
if err != nil {
|
|
t.Fatalf("GetInbound: %v", err)
|
|
}
|
|
cs, err := svc.GetClients(reloaded)
|
|
if err != nil {
|
|
t.Fatalf("GetClients: %v", err)
|
|
}
|
|
if len(cs) != 1 || cs[0].ExpiryTime <= 0 {
|
|
t.Errorf("inbound settings expiry not converted: %#v", cs)
|
|
}
|
|
}
|
|
|
|
// TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients locks in that the
|
|
// delayed-start persistence pass touches only clients adjustTraffics actually
|
|
// converted this poll: the delayed client's negative expiry becomes an absolute
|
|
// deadline while an already-absolute expiry passes through byte-identical.
|
|
// Before the fix every polled row got its own no-op expiry UPDATE.
|
|
func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
|
|
db := database.GetDB()
|
|
|
|
const delayedEmail = "delayed-mixed-user"
|
|
const normalEmail = "normal-mixed-user"
|
|
const delayedUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d002"
|
|
const normalUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d003"
|
|
const sevenDays = int64(7 * 86400000)
|
|
normalExpiry := time.Now().AddDate(0, 1, 0).UnixMilli()
|
|
|
|
clients := []model.Client{
|
|
{Email: delayedEmail, ID: delayedUID, Enable: true, ExpiryTime: -sevenDays},
|
|
{Email: normalEmail, ID: normalUID, Enable: true, ExpiryTime: normalExpiry},
|
|
}
|
|
inbound := &model.Inbound{
|
|
Tag: "vless-mixed", Enable: true, Port: 45002, Protocol: model.VLESS,
|
|
Settings: clientsSettings(t, clients),
|
|
}
|
|
if err := db.Create(inbound).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
|
|
svc := InboundService{}
|
|
if err := svc.clientService.SyncInbound(db, inbound.Id, clients); err != nil {
|
|
t.Fatalf("SyncInbound: %v", err)
|
|
}
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: delayedEmail, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
|
|
t.Fatalf("create delayed traffic row: %v", err)
|
|
}
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: normalEmail, Enable: true, ExpiryTime: normalExpiry}).Error; err != nil {
|
|
t.Fatalf("create normal traffic row: %v", err)
|
|
}
|
|
|
|
before := time.Now().UnixMilli()
|
|
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
|
|
{Email: delayedEmail, Up: 10, Down: 20},
|
|
{Email: normalEmail, Up: 30, Down: 40},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("addClientTraffic: %v", err)
|
|
}
|
|
|
|
var delayed xray.ClientTraffic
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", delayedEmail).First(&delayed).Error; err != nil {
|
|
t.Fatalf("reload delayed row: %v", err)
|
|
}
|
|
if delayed.ExpiryTime < before+sevenDays-5000 || delayed.ExpiryTime > before+sevenDays+5000 {
|
|
t.Errorf("delayed expiry = %d, want ~now+7d (%d)", delayed.ExpiryTime, before+sevenDays)
|
|
}
|
|
|
|
var normal xray.ClientTraffic
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", normalEmail).First(&normal).Error; err != nil {
|
|
t.Fatalf("reload normal row: %v", err)
|
|
}
|
|
if normal.ExpiryTime != normalExpiry {
|
|
t.Errorf("normal expiry changed: %d, want %d", normal.ExpiryTime, normalExpiry)
|
|
}
|
|
if normal.Up != 30 || normal.Down != 40 {
|
|
t.Errorf("normal traffic not applied: up=%d down=%d, want 30/40", normal.Up, normal.Down)
|
|
}
|
|
}
|
|
|
|
func TestAddTrafficClientUpdateFailureRollsBackWholeBatch(t *testing.T) {
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
db := database.GetDB()
|
|
|
|
for _, email := range []string{"healthy@x", "rejected@x"} {
|
|
if err := db.Create(&xray.ClientTraffic{Email: email, Enable: true}).Error; err != nil {
|
|
t.Fatalf("create client traffic %s: %v", email, err)
|
|
}
|
|
}
|
|
if err := db.Exec(`
|
|
CREATE TRIGGER reject_client_traffic_update
|
|
BEFORE UPDATE OF up, down ON client_traffics
|
|
WHEN OLD.email = 'rejected@x'
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'blocked client traffic update');
|
|
END`).Error; err != nil {
|
|
t.Fatalf("create update trigger: %v", err)
|
|
}
|
|
|
|
batch := []*xray.ClientTraffic{
|
|
{Email: "healthy@x", Up: 100, Down: 200},
|
|
{Email: "rejected@x", Up: 300, Down: 400},
|
|
}
|
|
svc := &InboundService{}
|
|
if _, _, err := svc.AddTraffic(nil, batch); err == nil {
|
|
t.Fatal("AddTraffic succeeded despite a client UPDATE failure")
|
|
}
|
|
assertTraffic := func(email string, up, down int64) {
|
|
t.Helper()
|
|
var got xray.ClientTraffic
|
|
if err := db.Where("email = ?", email).First(&got).Error; err != nil {
|
|
t.Fatalf("load traffic for %s: %v", email, err)
|
|
}
|
|
if got.Up != up || got.Down != down {
|
|
t.Fatalf("traffic for %s = (%d,%d), want (%d,%d)", email, got.Up, got.Down, up, down)
|
|
}
|
|
}
|
|
assertTraffic("healthy@x", 0, 0)
|
|
assertTraffic("rejected@x", 0, 0)
|
|
|
|
if err := db.Exec("DROP TRIGGER reject_client_traffic_update").Error; err != nil {
|
|
t.Fatalf("drop update trigger: %v", err)
|
|
}
|
|
if _, _, err := svc.AddTraffic(nil, batch); err != nil {
|
|
t.Fatalf("retry AddTraffic: %v", err)
|
|
}
|
|
assertTraffic("healthy@x", 100, 200)
|
|
assertTraffic("rejected@x", 300, 400)
|
|
}
|
|
|
|
func TestAddClientTrafficResolvesRenamedTuicClientByStableIdentity(t *testing.T) {
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
db := database.GetDB()
|
|
|
|
const (
|
|
inboundID = 18001
|
|
clientUUID = "a0000000-0000-0000-0000-000000000021"
|
|
oldEmail = "before-rename@x"
|
|
newEmail = "after-rename@x"
|
|
)
|
|
inbound := &model.Inbound{Id: inboundID, Tag: "tuic-rename", Enable: true, Port: 0, Protocol: model.TUIC}
|
|
if err := db.Create(inbound).Error; err != nil {
|
|
t.Fatalf("create inbound: %v", err)
|
|
}
|
|
client := &model.ClientRecord{Email: newEmail, UUID: clientUUID, Enable: true}
|
|
if err := db.Create(client).Error; err != nil {
|
|
t.Fatalf("create renamed client: %v", err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: inboundID}).Error; err != nil {
|
|
t.Fatalf("create client-inbound link: %v", err)
|
|
}
|
|
for _, email := range []string{oldEmail, newEmail} {
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: inboundID, Email: email, Enable: true}).Error; err != nil {
|
|
t.Fatalf("create traffic row %s: %v", email, err)
|
|
}
|
|
}
|
|
|
|
var currentTraffic xray.ClientTraffic
|
|
if err := db.Where("email = ?", newEmail).First(¤tTraffic).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stableTrafficID := currentTraffic.Id
|
|
|
|
if err := (&InboundService{}).addClientTraffic(db, []*xray.ClientTraffic{{
|
|
Email: oldEmail, TuicTrafficID: stableTrafficID, TuicUUID: clientUUID, TuicInboundId: inboundID, Up: 123, Down: 456,
|
|
}}); err != nil {
|
|
t.Fatalf("add retired snapshot traffic: %v", err)
|
|
}
|
|
for _, test := range []struct {
|
|
email string
|
|
up, down int64
|
|
}{{oldEmail, 0, 0}, {newEmail, 123, 456}} {
|
|
var got xray.ClientTraffic
|
|
if err := db.Where("email = ?", test.email).First(&got).Error; err != nil {
|
|
t.Fatalf("load traffic for %s: %v", test.email, err)
|
|
}
|
|
if got.Up != test.up || got.Down != test.down {
|
|
t.Errorf("traffic for %s = (%d,%d), want (%d,%d)", test.email, got.Up, got.Down, test.up, test.down)
|
|
}
|
|
}
|
|
}
|