feat(tuic): implement native in-process Go TUIC v5 server (#6577)

* 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>
This commit is contained in:
Egor
2026-10-03 05:56:02 +05:00
committed by GitHub
parent 40ca2cd72f
commit 0054e671f8
100 changed files with 7942 additions and 1467 deletions
+57 -30
View File
@@ -1,6 +1,7 @@
package job
import (
"fmt"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -18,6 +19,13 @@ func NewTuicJob() *TuicJob {
}
func (j *TuicJob) Run() {
tuicJournalMu.Lock()
journalErr := j.replayTuicJournal()
tuicJournalMu.Unlock()
if journalErr != nil {
logger.Warning("tuic job: recover traffic journal failed:", journalErr)
}
desired, err := j.inboundService.DesiredTuicInstances()
if err != nil {
logger.Warning("tuic job: get desired instances failed:", err)
@@ -32,41 +40,18 @@ func (j *TuicJob) Run() {
mgr := tuic.GetManager()
mgr.Reconcile(desired)
deltas := mgr.CollectTraffic()
_, clientDeltas := mgr.CollectAllTraffic()
onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
inboundUp := make(map[string]int64)
inboundDown := make(map[string]int64)
for _, d := range deltas {
inboundUp[d.Tag] += d.Up
inboundDown[d.Tag] += d.Down
}
clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
traffics := make([]*xray.Traffic, 0, len(inboundUp))
for tag, up := range inboundUp {
traffics = append(traffics, &xray.Traffic{
IsInbound: true,
Tag: tag,
Up: up,
Down: inboundDown[tag],
})
}
// Build zero-byte client traffic entries for active clients so adjustTraffics can
// activate delayed-start expiryTime for TUIC clients without inflating traffic.
clientTraffics := make([]*xray.ClientTraffic, 0, len(onlineEmails))
for _, email := range onlineEmails {
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: email,
Up: 0,
Down: 0,
})
}
if len(traffics) > 0 || len(clientTraffics) > 0 {
needRestart, _, err := j.inboundService.AddTraffic(traffics, clientTraffics)
// Inbound total traffic is already metered through the loopback SOCKS relay
// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
if len(clientTraffics) > 0 {
needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
if err != nil {
logger.Warning("tuic job: add traffic failed:", err)
mgr.RequeueClientTraffic(clientDeltas)
} else if needRestart {
if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
mgr.Reconcile(desired)
@@ -82,3 +67,45 @@ func (j *TuicJob) Run() {
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
}
// FlushStoppedTraffic persists counters drained when the TUIC manager stops its
// listeners. Call it after scheduled jobs have stopped and before the traffic
// writer shuts down.
func (j *TuicJob) FlushStoppedTraffic() error {
return j.flushTuicJournal()
}
func aggregateTuicClientTraffic(clientDeltas []tuic.ClientTrafficDelta, onlineEmails []string) []*xray.ClientTraffic {
clientTrafficMap := make(map[string]*xray.ClientTraffic, len(clientDeltas)+len(onlineEmails))
for _, cd := range clientDeltas {
key := cd.Email
if cd.TrafficID > 0 {
key = fmt.Sprintf("traffic:%d", cd.TrafficID)
}
if cd.TrafficID == 0 && cd.InboundID > 0 && cd.UUID != "" {
key = fmt.Sprintf("tuic:%d:%s", cd.InboundID, cd.UUID)
}
traffic := clientTrafficMap[key]
if traffic == nil {
traffic = &xray.ClientTraffic{Email: cd.Email, TuicTrafficID: cd.TrafficID, TuicUUID: cd.UUID, TuicInboundId: cd.InboundID}
clientTrafficMap[key] = traffic
}
traffic.Up += cd.Up
traffic.Down += cd.Down
}
for _, email := range onlineEmails {
if _, exists := clientTrafficMap[email]; !exists {
clientTrafficMap[email] = &xray.ClientTraffic{
Email: email,
Up: 0,
Down: 0,
}
}
}
clientTraffics := make([]*xray.ClientTraffic, 0, len(clientTrafficMap))
for _, ct := range clientTrafficMap {
clientTraffics = append(clientTraffics, ct)
}
return clientTraffics
}
+182
View File
@@ -0,0 +1,182 @@
package job
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func generateTestCertForJob(t *testing.T) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return
}
func TestTuicJob_TrafficAccounting(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "tuic_job.db")); err != nil {
t.Fatalf("database.InitDB failed: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
certPEM, keyPEM := generateTestCertForJob(t)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := pc.LocalAddr().(*net.UDPAddr).Port
_ = pc.Close()
email := "tuic-user@example.com"
settings := fmt.Sprintf(`{
"certificate": %q,
"private_key": %q,
"congestion_control": "bbr",
"alpn": ["h3"],
"udp_relay_mode": "native",
"zero_rtt_handshake": false,
"clients": [
{
"uuid": "a0000000-0000-0000-0000-000000000001",
"password": "password123",
"email": %q,
"enable": true
}
]
}`, string(certPEM), string(keyPEM), email)
inbound := &model.Inbound{
Id: 42,
Tag: "tuic-in-42",
Protocol: model.TUIC,
Listen: "127.0.0.1",
Port: port,
Enable: true,
Settings: settings,
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("create inbound failed: %v", err)
}
clientTraffic := &xray.ClientTraffic{
InboundId: inbound.Id,
Email: email,
Up: 0,
Down: 0,
Enable: true,
}
if err := database.GetDB().Create(clientTraffic).Error; err != nil {
t.Fatalf("create clientTraffic failed: %v", err)
}
mgr := tuic.GetManager()
t.Cleanup(mgr.StopAll)
job := NewTuicJob()
// Initial run reconciles desired instances and starts the server
job.Run()
// Add test traffic to the running client
const wantUp = int64(1024)
const wantDown = int64(2048)
if !mgr.AddTestTraffic(inbound.Id, email, wantUp, wantDown) {
t.Fatalf("failed to add test traffic for %s on inbound %d", email, inbound.Id)
}
// Second run collects and writes traffic to database
job.Run()
// Verify client traffic in database
var dbClient xray.ClientTraffic
if err := database.GetDB().Where("inbound_id = ? AND email = ?", inbound.Id, email).First(&dbClient).Error; err != nil {
t.Fatalf("find client traffic in DB failed: %v", err)
}
if dbClient.Up != wantUp || dbClient.Down != wantDown {
t.Fatalf("client traffic mismatch: got up=%d down=%d, want up=%d down=%d", dbClient.Up, dbClient.Down, wantUp, wantDown)
}
// Verify inbound total traffic in database: TuicJob leaves inbound total
// accounting to xray_traffic_job (metered on the SOCKS relay tag, matching mtproto),
// preventing double-counting.
var dbInbound model.Inbound
if err := database.GetDB().First(&dbInbound, inbound.Id).Error; err != nil {
t.Fatalf("find inbound in DB failed: %v", err)
}
if dbInbound.Up != 0 || dbInbound.Down != 0 {
t.Fatalf("expected inbound traffic to remain 0 in TuicJob (metered by Xray bridge), got up=%d down=%d", dbInbound.Up, dbInbound.Down)
}
}
func TestAggregateTuicClientTrafficSumsAcrossInbounds(t *testing.T) {
got := aggregateTuicClientTraffic([]tuic.ClientTrafficDelta{
{Email: "shared@example.test", Up: 100, Down: 200},
{Email: "shared@example.test", Up: 300, Down: 400},
{Email: "other@example.test", Up: 5, Down: 6},
}, []string{"shared@example.test", "online-only@example.test"})
byEmail := make(map[string]struct{ up, down int64 }, len(got))
for _, traffic := range got {
byEmail[traffic.Email] = struct{ up, down int64 }{traffic.Up, traffic.Down}
}
if shared := byEmail["shared@example.test"]; shared.up != 400 || shared.down != 600 {
t.Fatalf("shared client traffic = %+v, want (400, 600)", shared)
}
if other := byEmail["other@example.test"]; other.up != 5 || other.down != 6 {
t.Fatalf("other client traffic = %+v, want (5, 6)", other)
}
if online, ok := byEmail["online-only@example.test"]; !ok || online.up != 0 || online.down != 0 {
t.Fatalf("online-only client traffic = %+v, present=%v", online, ok)
}
if len(got) != 3 {
t.Fatalf("got %d aggregated clients, want 3", len(got))
}
}
func TestAggregateTuicClientTrafficPreservesStableIdentityAcrossEmailRename(t *testing.T) {
const (
inboundID = 82
clientUUID = "a0000000-0000-0000-0000-000000000082"
)
got := aggregateTuicClientTraffic([]tuic.ClientTrafficDelta{
{Email: "old@example.test", UUID: clientUUID, InboundID: inboundID, Up: 10, Down: 20},
{Email: "old@example.test", UUID: clientUUID, InboundID: inboundID, Up: 30, Down: 40},
}, nil)
if len(got) != 1 {
t.Fatalf("aggregate returned %d records, want 1", len(got))
}
if got[0].Email != "old@example.test" || got[0].TuicUUID != clientUUID || got[0].TuicInboundId != inboundID {
t.Fatalf("aggregate lost retired TUIC identity: %+v", got[0])
}
if got[0].Up != 40 || got[0].Down != 60 {
t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
}
}
+135
View File
@@ -0,0 +1,135 @@
package job
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
)
var tuicJournalMu sync.Mutex
type tuicTrafficBatch struct {
ID string `json:"id"`
Deltas []tuic.ClientTrafficDelta `json:"deltas"`
}
func tuicJournalDir() string { return filepath.Join(config.GetDBFolderPath(), "tuic-traffic-journal") }
func syncJournalDir(dir string) error {
if runtime.GOOS == "windows" {
return nil
}
file, err := os.Open(dir)
if err != nil {
return err
}
defer file.Close()
return file.Sync()
}
func storeTuicBatch(batch tuicTrafficBatch) (string, error) {
dir := tuicJournalDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
if err := syncJournalDir(filepath.Dir(dir)); err != nil {
return "", err
}
data, err := json.Marshal(batch)
if err != nil {
return "", err
}
file, err := os.CreateTemp(dir, ".pending-")
if err != nil {
return "", err
}
tmp := file.Name()
defer os.Remove(tmp)
if _, err := file.Write(data); err != nil {
file.Close()
return "", err
}
if err := file.Sync(); err != nil {
file.Close()
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
path := filepath.Join(dir, batch.ID+".json")
if err := os.Rename(tmp, path); err != nil {
return "", err
}
return path, syncJournalDir(dir)
}
func (j *TuicJob) replayTuicJournal() error {
entries, err := os.ReadDir(tuicJournalDir())
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
path := filepath.Join(tuicJournalDir(), entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return err
}
var batch tuicTrafficBatch
if err := json.Unmarshal(data, &batch); err != nil {
return fmt.Errorf("TUIC journal %s: %w", entry.Name(), err)
}
if batch.ID+".json" != entry.Name() {
return fmt.Errorf("TUIC journal batch ID mismatch")
}
if err := j.inboundService.AddTuicTrafficBatch(batch.ID, aggregateTuicClientTraffic(batch.Deltas, nil)); err != nil {
return err
}
if err := os.Remove(path); err != nil {
return err
}
if err := syncJournalDir(tuicJournalDir()); err != nil {
return err
}
}
return nil
}
func (j *TuicJob) flushTuicJournal() error {
tuicJournalMu.Lock()
defer tuicJournalMu.Unlock()
manager := tuic.GetManager()
_, deltas := manager.CollectAllTraffic()
if len(deltas) > 0 {
if path, err := storeTuicBatch(tuicTrafficBatch{ID: uuid.NewString(), Deltas: deltas}); err != nil {
if path == "" {
manager.RequeueClientTraffic(deltas)
}
return fmt.Errorf("persist TUIC shutdown journal: %w", err)
}
}
var err error
for attempt := 0; attempt < 3; attempt++ {
if err = j.replayTuicJournal(); err == nil {
return nil
}
if attempt < 2 {
time.Sleep(100 * time.Millisecond)
}
}
return fmt.Errorf("TUIC traffic retained in durable journal: %w", err)
}
+67
View File
@@ -0,0 +1,67 @@
package job
import (
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestTuicShutdownJournalSurvivesDatabaseFailureAndReplaysOnce(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dir)
dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
db := database.GetDB()
row := xray.ClientTraffic{Email: "journal@x", Enable: true}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
batch := tuicTrafficBatch{ID: "shutdown-batch", Deltas: []tuic.ClientTrafficDelta{{Email: row.Email, TrafficID: row.Id, Up: 123, Down: 456}}}
path, err := storeTuicBatch(batch)
if err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TRIGGER fail_tuic_write BEFORE UPDATE ON client_traffics BEGIN SELECT RAISE(FAIL, 'disk fault'); END`).Error; err != nil {
t.Fatal(err)
}
job := NewTuicJob()
if err := job.FlushStoppedTraffic(); err == nil {
t.Fatal("failed traffic update must return an error")
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("durable recovery file missing: %v", err)
}
var receipts int64
if err := db.Table("tuic_traffic_receipts").Count(&receipts).Error; err == nil && receipts != 0 {
t.Fatal("failed transaction committed a receipt")
}
if err := db.Exec("DROP TRIGGER fail_tuic_write").Error; err != nil {
t.Fatal(err)
}
// A new job has no knowledge of the previous process's in-memory deltas.
recovered := NewTuicJob()
if err := recovered.replayTuicJournal(); err != nil {
t.Fatal(err)
}
// Recreate a stale file, as if file removal was lost after DB commit.
if _, err := storeTuicBatch(batch); err != nil {
t.Fatal(err)
}
if err := recovered.replayTuicJournal(); err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", row.Id).First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Up != 123 || row.Down != 456 {
t.Fatalf("replay billed %d/%d", row.Up, row.Down)
}
entries, err := os.ReadDir(tuicJournalDir())
if err != nil || len(entries) != 0 {
t.Fatalf("journal not drained: %v %v", entries, err)
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ func TestProductionHTTPServersUseServeHTTPWrapper(t *testing.T) {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" || entry.Name() == "vendor" || entry.Name() == "node_modules" {
if entry.Name() == ".git" || entry.Name() == "vendor" || entry.Name() == "node_modules" || entry.Name() == "third_party" {
return filepath.SkipDir
}
return nil
+25 -1
View File
@@ -90,7 +90,11 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
if !ok {
return nil
}
return tuic.GetManager().Ensure(inst)
err := tuic.GetManager().Ensure(inst)
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return err
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
@@ -118,6 +122,9 @@ 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()
}
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
@@ -228,6 +235,9 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
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
}
@@ -235,11 +245,25 @@ 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)
+38
View File
@@ -0,0 +1,38 @@
package runtime
import (
"context"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
resyncs := 0
local := NewLocal(LocalDeps{SetNeedRestart: 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"
if err := local.updateTuicInbound(context.Background(), oldInbound, &newInbound); err != nil {
t.Fatalf("updateTuicInbound: %v", err)
}
if resyncs != 1 {
t.Fatalf("Xray bridge resync count = %d, want 1", resyncs)
}
}
func TestUpdateTuicInboundDoesNotResyncForProfileOnlyChange(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)
}
if resyncs != 0 {
t.Fatalf("profile-only update triggered %d Xray restarts, want 0", resyncs)
}
}
+87 -1
View File
@@ -1,8 +1,11 @@
package service
import (
"fmt"
"strings"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -102,6 +105,10 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
tx = database.GetDB()
}
if err := s.validateTuicIdentities(tx, inboundId, clients, detachEmails, prune); err != nil {
return err
}
emails := make([]string, 0, len(clients))
seen := make(map[string]struct{}, len(clients))
for i := range clients {
@@ -215,7 +222,42 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
wantedIds = append(wantedIds, id)
}
return s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
return err
}
affected := map[int]struct{}{inboundId: {}}
for _, ids := range chunkInts(wantedIds, sqlInChunk) {
var inboundIDs []int
if err := tx.Model(&model.ClientInbound{}).Distinct("inbound_id").Where("client_id IN ?", ids).Pluck("inbound_id", &inboundIDs).Error; err != nil {
return err
}
for _, id := range inboundIDs {
affected[id] = struct{}{}
}
}
affectedIDs := make([]int, 0, len(affected))
for id := range affected {
affectedIDs = append(affectedIDs, id)
}
for _, ids := range chunkInts(affectedIDs, sqlInChunk) {
var duplicates int64
err := tx.Raw(`SELECT COUNT(*) FROM (
SELECT ci.inbound_id, LOWER(c.uuid) FROM clients c
JOIN client_inbounds ci ON ci.client_id = c.id
JOIN inbounds i ON i.id = ci.inbound_id
WHERE i.protocol = ? AND c.uuid <> '' AND ci.inbound_id IN ?
GROUP BY ci.inbound_id, LOWER(c.uuid) HAVING COUNT(*) > 1
) AS duplicate_uuids`, model.TUIC, ids).Scan(&duplicates).Error
if err != nil {
return err
}
if duplicates > 0 {
return fmt.Errorf("TUIC: duplicate client UUID within inbound")
}
}
return nil
}
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
@@ -373,3 +415,47 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
}
return out, nil
}
func (s *ClientService) validateTuicIdentities(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
var inbound model.Inbound
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
return err
}
if inbound.Protocol != model.TUIC {
return nil
}
candidates := append([]model.Client(nil), changed...)
if !prune {
current, err := s.ListForInbound(tx, inboundID)
if err != nil {
return err
}
excluded := make(map[string]bool)
for _, client := range changed {
excluded[client.Email] = true
}
for _, email := range detached {
excluded[email] = true
}
for _, client := range current {
if !excluded[client.Email] {
candidates = append(candidates, client)
}
}
}
seen := make(map[uuid.UUID]string)
for _, client := range candidates {
if client.ID == "" {
continue
}
id, err := uuid.Parse(client.ID)
if err != nil {
return fmt.Errorf("TUIC: invalid client UUID")
}
if email, exists := seen[id]; exists && email != client.Email {
return fmt.Errorf("TUIC: duplicate client UUID within inbound")
}
seen[id] = client.Email
}
return nil
}
@@ -0,0 +1,32 @@
package service
import (
"fmt"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// PostgreSQL before 16 rejects a FROM subquery without an alias, so the TUIC
// duplicate-UUID guard must not break client sync for every protocol there.
func TestSyncInboundClientsOnPostgres(t *testing.T) {
db := durablePostgresDB(t)
suffix := time.Now().UnixNano()
inbound := durableTestInbound(nil, fmt.Sprintf("pg-sync-%d", suffix), 20000+int(suffix%20000))
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
email := fmt.Sprintf("pg-sync-%d@x", suffix)
t.Cleanup(func() {
_ = db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error
_ = db.Where("email = ?", email).Delete(&model.ClientRecord{}).Error
_ = db.Delete(&model.Inbound{}, inbound.Id).Error
})
clients := []model.Client{{ID: "8a9c1b2e-1111-4c3d-9e8f-000000000001", Email: email, Enable: true}}
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil {
t.Fatalf("SyncInbound on PostgreSQL: %v", err)
}
}
+56 -7
View File
@@ -26,6 +26,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -512,15 +513,22 @@ func inboundTuicServer(protocol string, settings string) *tuic.TuicServerSetting
if protocol != string(model.TUIC) || strings.TrimSpace(settings) == "" {
return nil
}
var parsed struct {
Server *tuic.TuicServerSettings `json:"server"`
}
if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
inst, ok := tuic.InstanceFromInbound(&model.Inbound{Protocol: model.TUIC, Settings: settings})
if !ok {
return nil
}
redacted := *parsed.Server
redacted.PrivateKey = ""
return &redacted
return &tuic.TuicServerSettings{
Certificate: inst.Certificate,
CongestionControl: inst.CongestionControl,
ALPN: inst.ALPN,
UDPRelayMode: inst.UDPRelayMode,
ZeroRTTHandshake: inst.ZeroRTTHandshake,
LogLevel: inst.LogLevel,
MaxIdleTime: inst.MaxIdleTime,
AuthenticationTimeout: inst.AuthenticationTimeout,
MaxUdpRelayPacketSize: inst.MaxUdpRelayPacketSize,
SNI: inst.SNI,
}
}
// inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
@@ -1129,6 +1137,9 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
// Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
inbound.Id = 0
if err := normalizeTuicSettings(inbound); err != nil {
return inbound, false, err
}
legacyShareAddr := legacyMtprotoShareAddr(inbound)
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// Normalize streamSettings based on protocol
@@ -1310,6 +1321,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
return aErr
}
}
if inbound.NodeID == nil && inbound.Protocol == model.TUIC {
if self := tuicSocksSelfConflict(inbound, inbound.Id); self != "" {
return common.NewError(self)
}
conflict, cErr := checkTuicSocksRelayCollision(tx, inbound.Id)
if cErr != nil {
return cErr
}
if conflict != nil {
return common.NewError(conflict.String())
}
conflict, cErr = checkTuicSocksReverseConflict(tx, inbound.Id)
if cErr != nil {
return cErr
}
if conflict != nil {
return common.NewError(conflict.String())
}
}
// Emails seeded here (import's ClientStats, e.g. the controller's forced
// Enable=true on every imported stat row) are authoritative for this call
// and must not be clobbered by the AddClientStat loop below, which derives
@@ -1772,6 +1802,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
// Restore the stored NodeID before any host-scoped check so a node inbound
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
inbound.NodeID = oldInbound.NodeID
if err := normalizeTuicSettings(inbound); err != nil {
return inbound, false, err
}
if err := s.normalizeAmneziaWGSettings(inbound, oldInbound.Settings); err != nil {
return inbound, false, err
}
@@ -2136,6 +2169,16 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
return nil, err
}
trafficIDs := make(map[string]int)
if inbound.Protocol == model.TUIC {
var rows []xray.ClientTraffic
if err := tx.Select("id", "email").Where("email IN ?", emails).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
trafficIDs[row.Email] = row.Id
}
}
finalClients := make([]any, 0, len(clients))
for _, client := range clients {
c, ok := client.(map[string]any)
@@ -2149,6 +2192,12 @@ func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
if inbound.Protocol == model.TUIC {
delete(c, "traffic_id")
if id := trafficIDs[email]; id > 0 {
c["traffic_id"] = id
}
}
finalClients = append(finalClients, c)
}
settings["clients"] = finalClients
+11 -2
View File
@@ -13,6 +13,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"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
@@ -380,10 +381,18 @@ func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, fo
}
return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
}
if ctx.onNode || ib.Protocol != model.AmneziaWG {
if ctx.onNode {
continue
}
var socksPort int
switch ib.Protocol {
case model.AmneziaWG:
socksPort = amneziawgnet.SOCKSPortForInbound(ib.Id)
case model.TUIC:
socksPort = tuic.SOCKSPortForInbound(ib.Id)
default:
continue
}
socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id)
if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) {
name := ib.Remark
if name == "" {
@@ -223,3 +223,109 @@ func TestAddClientTraffic_ExpiryWriteOnlyForConvertedClients(t *testing.T) {
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(&currentTraffic).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)
}
}
}
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
func TestBuildTargetClientFromSourceTuic(t *testing.T) {
@@ -64,3 +65,61 @@ func TestAddInboundTuicClientValidation(t *testing.T) {
t.Fatalf("expected 'empty client email' error, got %v", errNoEmail)
}
}
func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
ib := &model.Inbound{
Tag: "tuic-restart-test",
Protocol: model.TUIC,
Port: 54321,
Enable: true,
Settings: `{
"certificate": "dummy-cert",
"private_key": "dummy-key",
"clients":[{"id":"a0000000-0000-0000-0000-000000000001","password":"p1","email":"user1@tuic.com","enable":true}]
}`,
}
created, _, err := inboundSvc.AddInbound(ib)
if err != nil {
t.Fatalf("AddInbound failed: %v", err)
}
addPayload := &model.Inbound{
Id: created.Id,
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"p2","email":"user2@tuic.com","enable":true}]}`,
}
needRestart, err := clientSvc.AddInboundClient(inboundSvc, addPayload)
if err != nil {
t.Fatalf("AddInboundClient failed: %v", err)
}
if needRestart {
t.Fatal("expected needRestart = false when adding TUIC client")
}
updatePayload := &model.Inbound{
Id: created.Id,
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"new-p2","email":"user2@tuic.com","enable":true}]}`,
}
needRestart, err = clientSvc.UpdateInboundClient(inboundSvc, updatePayload, "user2@tuic.com")
if err != nil {
t.Fatalf("UpdateInboundClient failed: %v", err)
}
if needRestart {
t.Fatal("expected needRestart = false when updating TUIC client")
}
needRestart, err = clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "user2@tuic.com", false, true)
if err != nil {
t.Fatalf("DelInboundClientByEmail failed: %v", err)
}
if needRestart {
t.Fatal("expected needRestart = false when deleting TUIC client")
}
}
+45 -2
View File
@@ -133,6 +133,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
if len(traffics) == 0 {
return nil
}
traffics, err = canonicalizeClientTraffic(tx, traffics)
if err != nil {
return fmt.Errorf("resolve client traffic identities: %w", err)
}
emails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
@@ -150,6 +154,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
// conflict, but this filter was removed rather than relying on that ordering).
err = tx.Model(xray.ClientTraffic{}).
Where("email IN (?)", emails).
Order("id").
Find(&dbClientTraffics).Error
if err != nil {
return err
@@ -192,7 +197,7 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
),
t.Up, t.Down, now, ct.Email,
).Error; err != nil {
logger.Warning("AddClientTraffic update data ", err)
return fmt.Errorf("update traffic for %s: %w", ct.Email, err)
}
}
@@ -206,13 +211,51 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
`UPDATE client_traffics SET expiry_time = ? WHERE email = ? AND expiry_time < 0`,
convertedExpiryByEmail[email], email,
).Error; err != nil {
logger.Warning("AddClientTraffic update expiry_time ", err)
return fmt.Errorf("update expiry time for %s: %w", email, err)
}
}
return nil
}
func canonicalizeClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
byEmail := make(map[string]*xray.ClientTraffic, len(traffics))
for _, traffic := range traffics {
if traffic == nil {
continue
}
email := traffic.Email
if traffic.TuicTrafficID > 0 {
var owner xray.ClientTraffic
err := tx.Select("email").Where("id = ?", traffic.TuicTrafficID).Take(&owner).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
if err != nil {
return nil, err
}
email = owner.Email
}
current := byEmail[email]
if current == nil {
copy := *traffic
copy.Email = email
copy.TuicUUID = ""
copy.TuicInboundId = 0
byEmail[email] = &copy
continue
}
current.Up += traffic.Up
current.Down += traffic.Down
current.Enable = current.Enable || traffic.Enable
}
result := make([]*xray.ClientTraffic, 0, len(byEmail))
for _, traffic := range byEmail {
result = append(result, traffic)
}
return result, nil
}
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, map[string]int64, error) {
now := time.Now().UnixMilli()
+5 -1
View File
@@ -24,7 +24,11 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
instances := make([]tuic.Instance, 0, len(inbounds))
for _, ib := range inbounds {
inst, ok := tuic.InstanceFromInbound(ib)
built, err := s.buildInboundForLocalRuntime(db, ib)
if err != nil {
return nil, err
}
inst, ok := tuic.InstanceFromInbound(built)
if !ok {
continue
}
+337
View File
@@ -0,0 +1,337 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestInjectTuicSocks(t *testing.T) {
cfg := &xray.Config{}
inbounds := []*model.Inbound{
{
Id: 5,
Tag: "tuic-in-5",
Protocol: model.TUIC,
Enable: true,
Settings: `{
"certificate": "dummy-cert",
"private_key": "dummy-key",
"clients": [
{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "pass1", "email": "user1@example.com", "enable": true}
]
}`,
},
}
injectTuicSocks(cfg, inbounds)
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("expected 1 injected SOCKS inbound, got %d", len(cfg.InboundConfigs))
}
sc := cfg.InboundConfigs[0]
if sc.Tag != "tuic-in-5" {
t.Fatalf("expected tag tuic-in-5, got %s", sc.Tag)
}
if sc.Protocol != "socks" {
t.Fatalf("expected protocol socks, got %s", sc.Protocol)
}
expectedPort := tuic.SOCKSPortForInbound(5)
if sc.Port != expectedPort {
t.Fatalf("expected port %d, got %d", expectedPort, sc.Port)
}
if string(sc.Listen) != `"127.0.0.1"` {
t.Fatalf("expected listen 127.0.0.1, got %s", sc.Listen)
}
if string(sc.Sniffing) != tuicEgressSniffingSettings {
t.Fatalf("expected sniffing settings %s, got %s", tuicEgressSniffingSettings, sc.Sniffing)
}
var parsedSettings struct {
Auth string `json:"auth"`
UDP bool `json:"udp"`
}
if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
t.Fatalf("failed to unmarshal settings: %v", err)
}
if parsedSettings.Auth != "noauth" || !parsedSettings.UDP {
t.Fatalf("expected auth=noauth, udp=true, got %+v", parsedSettings)
}
}
func TestCheckTuicSocksConflict(t *testing.T) {
setupConflictDB(t)
// Seed TUIC inbound with ID 10
tuicIb := &model.Inbound{
Id: 10,
Tag: "tuic-10",
Protocol: model.TUIC,
Enable: true,
Listen: "0.0.0.0",
Port: 8443,
Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"u@test.com"}]}`,
}
if err := database.GetDB().Create(tuicIb).Error; err != nil {
t.Fatalf("failed to seed TUIC inbound: %v", err)
}
relayPort := tuic.SOCKSPortForInbound(10)
// Try to create a new TCP inbound on that relayPort on 127.0.0.1
newIb := &model.Inbound{
Tag: "colliding-inbound",
Protocol: model.Mixed,
Enable: true,
Listen: "127.0.0.1",
Port: relayPort,
}
detail, err := checkTuicSocksConflict(database.GetDB(), newIb, 0, transportTCP)
if err != nil {
t.Fatalf("checkTuicSocksConflict error: %v", err)
}
if detail == nil {
t.Fatalf("expected conflict on port %d, got none", relayPort)
}
if detail.Tag != "tuic-10" {
t.Fatalf("expected conflict tag tuic-10, got %s", detail.Tag)
}
}
func TestCheckTuicSocksReverseConflict(t *testing.T) {
setupConflictDB(t)
targetPort := tuic.SOCKSPortForInbound(20)
// Seed existing inbound on targetPort on 127.0.0.1
existing := &model.Inbound{
Id: 99,
Tag: "existing-on-relay-port",
Protocol: model.Mixed,
Enable: true,
Listen: "127.0.0.1",
Port: targetPort,
}
if err := database.GetDB().Create(existing).Error; err != nil {
t.Fatalf("failed to seed existing inbound: %v", err)
}
detail, err := checkTuicSocksReverseConflict(database.GetDB(), 20)
if err != nil {
t.Fatalf("checkTuicSocksReverseConflict error: %v", err)
}
if detail == nil {
t.Fatalf("expected reverse conflict for id 20 on port %d, got none", targetPort)
}
if detail.Tag != "existing-on-relay-port" {
t.Fatalf("expected tag existing-on-relay-port, got %s", detail.Tag)
}
}
func TestDesiredTuicInstances(t *testing.T) {
setupConflictDB(t)
ib := &model.Inbound{
Id: 30,
Tag: "tuic-desired-test",
Protocol: model.TUIC,
Enable: true,
Listen: "0.0.0.0",
Port: 9443,
Settings: `{
"certificate": "cert",
"private_key": "key",
"clients": [
{"uuid": "a0000000-0000-0000-0000-000000000001", "password": "p1", "email": "active@test.com", "enable": true},
{"uuid": "a0000000-0000-0000-0000-000000000002", "password": "p2", "email": "disabled@test.com", "enable": true}
]
}`,
}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("failed to seed inbound: %v", err)
}
// Add client traffic entry disabling disabled@test.com
ct := &xray.ClientTraffic{
InboundId: 30,
Email: "disabled@test.com",
Enable: false,
}
if err := database.GetDB().Create(ct).Error; err != nil {
t.Fatalf("failed to seed client traffic: %v", err)
}
svc := &InboundService{}
instances, err := svc.DesiredTuicInstances()
if err != nil {
t.Fatalf("DesiredTuicInstances failed: %v", err)
}
found := false
for _, inst := range instances {
if inst.Id == 30 {
found = true
if len(inst.Clients) != 1 || inst.Clients[0].Email != "active@test.com" {
t.Fatalf("expected only active@test.com, got %+v", inst.Clients)
}
}
}
if !found {
t.Fatal("expected to find instance for inbound 30")
}
}
func TestCheckForwardedPortsConflict_CollidesWithTuicSocksPort(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "tuic-1", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
var tuicInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "tuic-1").First(&tuicInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := tuic.SOCKSPortForInbound(tuicInbound.Id)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB(), nil)
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
if !strings.Contains(hit, "SOCKS5") {
t.Fatalf("expected a collision naming the TUIC inbound's SOCKS5 relay port, got %q", hit)
}
}
func TestCheckTuicSocksConflict_DisabledInboundRetainsReservation(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "tuic-disabled", "0.0.0.0", 8443, model.TUIC, ``, `{"clients":[{"uuid":"u","password":"p","email":"e"}]}`)
var tuicIb model.Inbound
if err := database.GetDB().Where("tag = ?", "tuic-disabled").First(&tuicIb).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
if err := database.GetDB().Model(&tuicIb).Update("enable", false).Error; err != nil {
t.Fatalf("disable inbound: %v", err)
}
relayPort := tuic.SOCKSPortForInbound(tuicIb.Id)
testIb := &model.Inbound{
Tag: "conflict-test",
Protocol: model.VLESS,
Listen: "127.0.0.1",
Port: relayPort,
Enable: true,
}
conflict, err := checkTuicSocksConflict(database.GetDB(), testIb, 0, transportTCP)
if err != nil {
t.Fatalf("checkTuicSocksConflict: %v", err)
}
if conflict == nil {
t.Fatal("expected conflict on disabled TUIC inbound's SOCKS port, got nil")
}
if conflict.InboundID != tuicIb.Id {
t.Fatalf("expected conflict with inbound %d, got %d", tuicIb.Id, conflict.InboundID)
}
}
func TestCheckTuicSocksRelayCollision(t *testing.T) {
setupConflictDB(t)
// Seed first TUIC inbound with ID 1
ib1 := &model.Inbound{
Id: 1,
Tag: "tuic-1",
Protocol: model.TUIC,
Enable: true,
Listen: "0.0.0.0",
Port: 8443,
Settings: `{"clients":[{"uuid":"a0000000-0000-0000-0000-000000000001","password":"p","email":"u1@test.com"}]}`,
}
if err := database.GetDB().Create(ib1).Error; err != nil {
t.Fatalf("seed ib1: %v", err)
}
// ID 1001 wraps to the same relay port (64001) as ID 1
conflict, err := checkTuicSocksRelayCollision(database.GetDB(), 1001)
if err != nil {
t.Fatalf("checkTuicSocksRelayCollision: %v", err)
}
if conflict == nil {
t.Fatal("expected collision between ID 1001 and ID 1, got nil")
}
if conflict.InboundID != 1 {
t.Fatalf("expected collision with inbound 1, got %d", conflict.InboundID)
}
}
func TestTuicSocksSelfConflict(t *testing.T) {
ib := &model.Inbound{
Protocol: model.TUIC,
Listen: "127.0.0.1",
Port: tuic.SOCKSPortForInbound(5),
}
errStr := tuicSocksSelfConflict(ib, 5)
if errStr == "" {
t.Fatal("expected self conflict error string, got empty")
}
if !strings.Contains(errStr, "own SOCKS5 relay port") {
t.Fatalf("unexpected error string: %s", errStr)
}
// Different port should not conflict
ib.Port = 9999
if diff := tuicSocksSelfConflict(ib, 5); diff != "" {
t.Fatalf("expected no conflict for different port, got %s", diff)
}
}
func TestInboundTuicServerParsesLegacyFlatSettings(t *testing.T) {
server := inboundTuicServer(string(model.TUIC), `{"certificate":"/cert.pem","private_key":"/secret-key.pem","congestion_control":" CuBiC ","udp_relay_mode":"quic","sni":"profile.example"}`)
if server == nil {
t.Fatal("expected legacy flat TUIC settings")
}
if server.CongestionControl != "cubic" || server.UDPRelayMode != "quic" || server.SNI != "profile.example" {
t.Fatalf("legacy flat fields were not normalized: %+v", server)
}
if server.PrivateKey != "" {
t.Fatal("client preview exposed the inbound private key")
}
}
func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T) {
ib := &model.Inbound{Protocol: model.TUIC, Settings: `{"congestion_control":"RENO","max_udp_relay_packet_size":65507,"server":{"congestion_control":" CuBiC ","max_udp_relay_packet_size":65500}}`}
if err := normalizeTuicSettings(ib); err != nil {
t.Fatalf("normalizeTuicSettings: %v", err)
}
var got struct {
CongestionControl string `json:"congestion_control"`
MaxPacketSize int `json:"max_udp_relay_packet_size"`
Server struct {
CongestionControl string `json:"congestion_control"`
MaxPacketSize int `json:"max_udp_relay_packet_size"`
} `json:"server"`
}
if err := json.Unmarshal([]byte(ib.Settings), &got); err != nil {
t.Fatalf("unmarshal normalized settings: %v", err)
}
if got.CongestionControl != "new_reno" || got.Server.CongestionControl != "cubic" {
t.Fatalf("congestion controllers were not canonicalized: %+v", got)
}
if got.MaxPacketSize != 65245 || got.Server.MaxPacketSize != 65245 {
t.Fatalf("packet limits were not clamped: %+v", got)
}
ib.Settings = `{"server":{"congestion_control":"experimental"}}`
if err := normalizeTuicSettings(ib); err == nil {
t.Fatal("unsupported congestion controller was accepted")
}
}
+117
View File
@@ -10,6 +10,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"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/util/common"
"gorm.io/gorm"
@@ -284,6 +285,13 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
if conflict != nil {
return conflict, nil
}
conflict, err = checkTuicSocksConflict(db, inbound, ignoreId, newBits)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
}
// The reverse direction, only meaningful once the id is known -- AddInbound
@@ -318,6 +326,25 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
forwardedBy.Transports = newBits
return forwardedBy, nil
}
if inbound.NodeID == nil && inbound.Protocol == model.TUIC && ignoreId > 0 {
if self := tuicSocksSelfConflict(inbound, ignoreId); self != "" {
return nil, common.NewError(self)
}
conflict, err := checkTuicSocksRelayCollision(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
conflict, err = checkTuicSocksReverseConflict(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
}
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
@@ -488,6 +515,96 @@ func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDe
return nil, nil
}
func checkTuicSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("protocol = ? AND node_id IS NULL", model.TUIC)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
if err := q.Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if _, ok := tuic.InstanceFromInbound(c); !ok {
continue
}
if tuic.SOCKSPortForInbound(c.Id) != inbound.Port {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: newBits,
}, nil
}
return nil, nil
}
func checkTuicSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := tuic.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("protocol = ? AND node_id IS NULL AND id != ?", model.TUIC, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if tuic.SOCKSPortForInbound(c.Id) != relayPort {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: relayPort,
Relay: true,
Transports: transportTCP,
}, nil
}
return nil, nil
}
func tuicSocksSelfConflict(inbound *model.Inbound, id int) string {
if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
return ""
}
relayPort := tuic.SOCKSPortForInbound(id)
if inbound.Port != relayPort {
return ""
}
return fmt.Sprintf("TUIC port %d is inbound #%d's own SOCKS5 relay port on 127.0.0.1; choose a different TUIC port",
relayPort, id)
}
func checkTuicSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := tuic.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: c.Listen,
Port: relayPort,
Relay: true,
Transports: transportTCP,
}, nil
}
return nil, nil
}
func sameNode(a, b *int) bool {
if a == nil && b == nil {
return true
@@ -0,0 +1,151 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func audit3CreateTuicClient(t *testing.T, svc *ClientService, inboundSvc *InboundService, ib *model.Inbound, email, id string) model.ClientRecord {
t.Helper()
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: model.Client{Email: email, ID: id, Password: "pw", Enable: true}, InboundIds: []int{ib.Id}}); err != nil {
t.Fatalf("public Create %s: %v", email, err)
}
return lookupClientRecord(t, email)
}
func audit3Traffic(t *testing.T, email string) xray.ClientTraffic {
t.Helper()
var out xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&out).Error; err != nil {
t.Fatal(err)
}
return out
}
func TestAudit3DeletedIdentityMustNotChargeDuplicateUUID(t *testing.T) {
setupBulkDB(t)
svc, inboundSvc := &ClientService{}, &InboundService{}
ibB := mkInbound(t, 28001, model.TUIC, `{"clients":[]}`)
ibA := mkInbound(t, 28002, model.TUIC, `{"clients":[]}`)
const id = "a0000000-0000-0000-0000-000000000031"
audit3CreateTuicClient(t, svc, inboundSvc, ibB, "b@audit3", id)
a := audit3CreateTuicClient(t, svc, inboundSvc, ibA, "a@audit3", id)
trafficID := audit3RuntimeTrafficID(t, inboundSvc, ibA, "a@audit3")
if _, err := svc.Delete(inboundSvc, a.Id, true); err != nil {
t.Fatalf("public Delete keepTraffic: %v", err)
}
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "a@audit3", TuicTrafficID: trafficID, TuicUUID: id, TuicInboundId: ibA.Id, Up: 123, Down: 456}}); err != nil {
t.Fatal(err)
}
old, other := audit3Traffic(t, "a@audit3"), audit3Traffic(t, "b@audit3")
if old.Up != 123 || old.Down != 456 || other.Up != 0 || other.Down != 0 {
t.Fatalf("retired A=%d/%d; unrelated B=%d/%d, want A=123/456 and B=0/0", old.Up, old.Down, other.Up, other.Down)
}
}
func TestAudit3RenameAndRotateCredentialsMustPreserveRetiredUsage(t *testing.T) {
setupBulkDB(t)
svc, inboundSvc := &ClientService{}, &InboundService{}
ib := mkInbound(t, 28003, model.TUIC, `{"clients":[]}`)
const oldID = "a0000000-0000-0000-0000-000000000032"
a := audit3CreateTuicClient(t, svc, inboundSvc, ib, "old@audit3", oldID)
trafficID := audit3RuntimeTrafficID(t, inboundSvc, ib, "old@audit3")
if _, err := svc.Update(inboundSvc, a.Id, model.Client{Email: "new@audit3", ID: "a0000000-0000-0000-0000-000000000033", Password: "newpw", Enable: true}, 0); err != nil {
t.Fatalf("public rename + rotate: %v", err)
}
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "old@audit3", TuicTrafficID: trafficID, TuicUUID: oldID, TuicInboundId: ib.Id, Up: 123, Down: 456}}); err != nil {
t.Fatal(err)
}
got := audit3Traffic(t, "new@audit3")
if got.Up != 123 || got.Down != 456 {
t.Fatalf("new account usage=%d/%d, want retired 123/456", got.Up, got.Down)
}
}
func TestAudit3PublicRenamePreservesRetiredUsage(t *testing.T) {
setupBulkDB(t)
svc, inboundSvc := &ClientService{}, &InboundService{}
ib := mkInbound(t, 28004, model.TUIC, `{"clients":[]}`)
const id = "a0000000-0000-0000-0000-000000000034"
a := audit3CreateTuicClient(t, svc, inboundSvc, ib, "old@audit3", id)
trafficID := audit3RuntimeTrafficID(t, inboundSvc, ib, "old@audit3")
if _, err := svc.Update(inboundSvc, a.Id, model.Client{Email: "new@audit3", ID: id, Password: "pw", Enable: true}, 0); err != nil {
t.Fatalf("public rename: %v", err)
}
if _, _, err := inboundSvc.AddTraffic(nil, []*xray.ClientTraffic{{Email: "old@audit3", TuicTrafficID: trafficID, TuicUUID: id, TuicInboundId: ib.Id, Up: 123, Down: 456}}); err != nil {
t.Fatal(err)
}
got := audit3Traffic(t, "new@audit3")
if got.Up != 123 || got.Down != 456 {
t.Fatalf("new account usage=%d/%d, want retired 123/456", got.Up, got.Down)
}
}
func TestAudit3SameInboundDuplicateUUIDMustBeRejectedOrBillMatchedPrincipal(t *testing.T) {
setupBulkDB(t)
svc, inboundSvc := &ClientService{}, &InboundService{}
ib := mkInbound(t, 28005, model.TUIC, `{"clients":[]}`)
const id = "a0000000-0000-0000-0000-000000000035"
audit3CreateTuicClient(t, svc, inboundSvc, ib, "a@audit3", id)
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: model.Client{Email: "b@audit3", ID: id, Password: "different-pw", Enable: true}, InboundIds: []int{ib.Id}}); err != nil {
t.Logf("duplicate correctly rejected: %v", err)
return
}
t.Fatal("public Create accepted duplicate UUID")
}
func audit3RuntimeTrafficID(t *testing.T, service *InboundService, inbound *model.Inbound, email string) int {
t.Helper()
fresh, err := service.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
built, err := service.buildInboundForLocalRuntime(database.GetDB(), fresh)
if err != nil {
t.Fatal(err)
}
instance, ok := tuic.InstanceFromInbound(built)
if !ok {
t.Fatal("invalid runtime settings")
}
for _, client := range instance.Clients {
if client.Email == email {
if client.TrafficID == 0 || client.TrafficID != audit3Traffic(t, email).Id {
t.Fatal("runtime snapshot lacks immutable accounting identity")
}
return client.TrafficID
}
}
t.Fatal("runtime client missing")
return 0
}
func TestTuicDeletedTrafficRowCannotBillReusedEmail(t *testing.T) {
setupBulkDB(t)
db := database.GetDB()
old := xray.ClientTraffic{Email: "reused@audit3", Enable: true}
if err := db.Create(&old).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&old).Error; err != nil {
t.Fatal(err)
}
replacement := xray.ClientTraffic{Email: old.Email, Enable: true}
if err := db.Create(&replacement).Error; err != nil {
t.Fatal(err)
}
if old.Id == replacement.Id {
t.Fatal("accounting primary key reused")
}
if _, _, err := (&InboundService{}).AddTraffic(nil, []*xray.ClientTraffic{{Email: old.Email, TuicTrafficID: old.Id, Up: 123, Down: 456}}); err != nil {
t.Fatal(err)
}
got := audit3Traffic(t, old.Email)
if got.Up != 0 || got.Down != 0 {
t.Fatalf("retired client billed replacement: %+v", got)
}
}
+108
View File
@@ -0,0 +1,108 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
)
func normalizeTuicSettings(inbound *model.Inbound) error {
if inbound == nil || inbound.Protocol != model.TUIC || inbound.Settings == "" {
return nil
}
var root map[string]json.RawMessage
if err := json.Unmarshal([]byte(inbound.Settings), &root); err != nil {
return fmt.Errorf("invalid TUIC settings: %w", err)
}
if err := normalizeTuicSettingsBlock(root); err != nil {
return err
}
if rawServer, ok := root["server"]; ok && len(rawServer) > 0 {
var server map[string]json.RawMessage
if err := json.Unmarshal(rawServer, &server); err != nil {
return fmt.Errorf("invalid TUIC server settings: %w", err)
}
if err := normalizeTuicSettingsBlock(server); err != nil {
return err
}
encoded, err := json.Marshal(server)
if err != nil {
return fmt.Errorf("encode TUIC server settings: %w", err)
}
root["server"] = encoded
}
encoded, err := json.Marshal(root)
if err != nil {
return fmt.Errorf("encode TUIC settings: %w", err)
}
inbound.Settings = string(encoded)
return nil
}
func normalizeTuicSettingsBlock(settings map[string]json.RawMessage) error {
if raw, ok := settings["congestion_control"]; ok {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return fmt.Errorf("TUIC congestion_control must be a string: %w", err)
}
normalized, err := tuic.NormalizeCongestionControl(value)
if err != nil {
return err
}
encoded, _ := json.Marshal(normalized)
settings["congestion_control"] = encoded
}
if raw, ok := settings["log_level"]; ok {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return fmt.Errorf("TUIC log_level must be a string: %w", err)
}
value = strings.ToLower(strings.TrimSpace(value))
if value == "warning" {
value = "warn"
}
if value == "" {
value = "info"
}
switch value {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("TUIC log_level %q is unsupported", value)
}
encoded, _ := json.Marshal(value)
settings["log_level"] = encoded
}
if raw, ok := settings["udp_relay_mode"]; ok {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return fmt.Errorf("TUIC udp_relay_mode must be a string: %w", err)
}
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
value = "native"
}
if value != "native" && value != "quic" {
return fmt.Errorf("TUIC udp_relay_mode %q is unsupported", value)
}
encoded, _ := json.Marshal(value)
settings["udp_relay_mode"] = encoded
}
if raw, ok := settings["max_udp_relay_packet_size"]; ok {
var value int
if err := json.Unmarshal(raw, &value); err != nil {
return fmt.Errorf("TUIC max_udp_relay_packet_size must be an integer: %w", err)
}
if value > 65507 {
return fmt.Errorf("TUIC max_udp_relay_packet_size must not exceed %d", 65245)
}
if value > 65245 {
value = 65245
}
encoded, _ := json.Marshal(value)
settings["max_udp_relay_packet_size"] = encoded
}
return nil
}
@@ -0,0 +1,32 @@
package service
import (
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// AddTuicTrafficBatch commits a journal batch and its receipt atomically. A
// retained journal can therefore be replayed after an ambiguous commit result.
func (s *InboundService) AddTuicTrafficBatch(id string, traffic []*xray.ClientTraffic) error {
if id == "" {
return errors.New("TUIC traffic batch has no ID")
}
return submitTrafficWrite(func() error {
return database.GetDB().Transaction(func(tx *gorm.DB) error {
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
return s.addClientTraffic(tx, traffic)
})
})
}
+34
View File
@@ -16,6 +16,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/config"
"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/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -426,6 +427,7 @@ 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)
// 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
@@ -793,6 +795,38 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
}
}
const (
tuicEgressSocksSettings = `{"auth":"noauth","udp":true}`
tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
)
func injectTuicSocks(cfg *xray.Config, inbounds []*model.Inbound) {
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
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 {
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
}
existingTags[inbound.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,
})
}
}
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
// tag for one peer's IPv6 source-identity egress. Stable across config
// regenerations (a pure function of two stable identifiers), so
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "يوم التجديد",
"renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
"renewsUsed": "التجديدات المستخدمة",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "التحكم في الازدحام",
"congestionControlHint": "خوارزمية الخادم والعميل. تسري تغييرات الخادم بعد إعادة الاتصال.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "وضع ترحيل UDP",
"udpRelayModeHint": "وضع UDP للعميل. يدعم الخادم كلا الوضعين.",
"zeroRttHandshake": "مصافحة 0-RTT",
"logLevel": "مستوى السجل",
"logLevelHint": "يتم استنتاج حالة الاتصال وآخر ظهور و«البدء بعد أول استخدام» من سطور Info الخاصة بالعملية الجانبية؛ اختيار Warn أو Error يعطّلها لهذا الإنباوند.",
"logLevelHint": "أدنى مستوى للسجل لهذا الاتصال الوارد.",
"maxIdleTime": "أقصى وقت خمول (ثوانٍ)",
"authTimeout": "مهلة المصادقة (ثوانٍ)",
"maxUdpRelayPacketSize": "أقصى حجم لحزمة UDP",
"sni": "SNI",
"sniHint": "تجاوز إشارة اسم الخادم"
"sniHint": "اسم خادم TLS في ملف إعداد العميل.",
"sniRequired": "أدخل نطاقًا لملء مسارات الشهادة تلقائيًا."
},
"tun": {
"userLevel": "مستوى المستخدم"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Renew on day",
"renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
"renewsUsed": "Renewals used",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds."
},
"groups": {
@@ -2256,16 +2255,20 @@
},
"tuic": {
"congestionControl": "Congestion Control",
"congestionControlHint": "Server and client algorithm. Server changes apply after reconnecting.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "UDP Relay Mode",
"udpRelayModeHint": "Client UDP mode. The server supports both.",
"zeroRttHandshake": "Zero-RTT Handshake",
"logLevel": "Log Level",
"logLevelHint": "Online status, last online and \"start after first use\" are read from the sidecar's Info lines; Warn or Error turns them off for this inbound.",
"logLevelHint": "Minimum log level for this inbound.",
"maxIdleTime": "Max Idle Time (s)",
"authTimeout": "Auth Timeout (s)",
"maxUdpRelayPacketSize": "Max UDP Packet Size",
"sni": "SNI",
"sniHint": "Server Name Indication override"
"sniHint": "TLS server name in the client profile.",
"sniRequired": "Enter a domain to auto-fill certificate paths."
},
"tun": {
"userLevel": "User Level"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Renovar el día",
"renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
"renewsUsed": "Renovaciones usadas",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Control de congestión",
"congestionControlHint": "Algoritmo del servidor y cliente. Los cambios del servidor se aplican al reconectar.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "Modo de retransmisión UDP",
"udpRelayModeHint": "Modo UDP del cliente. El servidor admite ambos.",
"zeroRttHandshake": "Apretón de manos 0-RTT",
"logLevel": "Nivel de registro",
"logLevelHint": "El estado en línea, la última conexión y «iniciar tras el primer uso» se leen de las líneas Info del sidecar; Warn o Error los desactivan para este inbound.",
"logLevelHint": "Nivel mínimo de registro de esta entrada.",
"maxIdleTime": "Tiempo máx. de inactividad (s)",
"authTimeout": "Tiempo de espera de autenticación (s)",
"maxUdpRelayPacketSize": "Tamaño máx. de paquete UDP",
"sni": "SNI",
"sniHint": "Sobrescritura de Server Name Indication"
"sniHint": "Nombre del servidor TLS en el perfil del cliente.",
"sniRequired": "Introduce un dominio para completar las rutas del certificado."
},
"tun": {
"userLevel": "Nivel de Usuario"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "روز تمدید",
"renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
"renewsUsed": "تمدیدهای استفاده‌شده",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "کنترل ازدحام",
"congestionControlHint": "الگوریتم سرور و کلاینت. تغییر سرور پس از اتصال مجدد اعمال می‌شود.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "حالت رله UDP",
"udpRelayModeHint": "حالت UDP کلاینت. سرور از هر دو پشتیبانی می‌کند.",
"zeroRttHandshake": "دست دادن 0-RTT",
"logLevel": "سطح گزارش",
"logLevelHint": "وضعیت آنلاین، آخرین اتصال و «شروع پس از اولین استفاده» از خطوط Info سایدکار خوانده می‌شوند؛ Warn یا Error آن‌ها را برای این اینباند غیرفعال می‌کند.",
"logLevelHint": "حداقل سطح لاگ این ورودی.",
"maxIdleTime": "حداکثر زمان بیکاری (ثانیه)",
"authTimeout": "مهلت زمانی احراز هویت (ثانیه)",
"maxUdpRelayPacketSize": "حداکثر اندازه بسته UDP",
"sni": "SNI",
"sniHint": "بازنویسی نشانگر نام سرور"
"sniHint": "نام سرور TLS در پروفایل کلاینت.",
"sniRequired": "برای تکمیل خودکار مسیرهای گواهی، دامنه را وارد کنید."
},
"tun": {
"userLevel": "سطح کاربر"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Perpanjang pada tanggal",
"renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
"renewsUsed": "Perpanjangan terpakai",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Kontrol Kemacetan",
"congestionControlHint": "Algoritma server dan klien. Perubahan server berlaku setelah terhubung ulang.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "Mode Relai UDP",
"udpRelayModeHint": "Mode UDP klien. Server mendukung keduanya.",
"zeroRttHandshake": "Jabat Tangan 0-RTT",
"logLevel": "Tingkat Log",
"logLevelHint": "Status online, terakhir online, dan \"mulai setelah penggunaan pertama\" dibaca dari baris Info sidecar; Warn atau Error menonaktifkannya untuk inbound ini.",
"logLevelHint": "Level log minimum untuk inbound ini.",
"maxIdleTime": "Waktu Diam Maks (dtk)",
"authTimeout": "Batas Waktu Otentikasi (dtk)",
"maxUdpRelayPacketSize": "Ukuran Paket UDP Maks",
"sni": "SNI",
"sniHint": "Penggantian Server Name Indication"
"sniHint": "Nama server TLS dalam profil klien.",
"sniRequired": "Masukkan domain untuk mengisi jalur sertifikat otomatis."
},
"tun": {
"userLevel": "Level Pengguna"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "更新する日",
"renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
"renewsUsed": "使用済み更新回数",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。"
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "輻輳制御",
"congestionControlHint": "サーバーとクライアントの方式。サーバーへの変更は再接続後に適用。",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "UDP リレーモード",
"udpRelayModeHint": "クライアントの UDP モード。サーバーは両方に対応。",
"zeroRttHandshake": "0-RTT ハンドシェイク",
"logLevel": "ログレベル",
"logLevelHint": "オンライン状態、最終オンライン、「初回使用後に開始」はサイドカーの Info 行から検出されます。Warn または Error を選ぶとこのインバウンドでは無効になります。",
"logLevelHint": "このインバウンドの最低ログレベル。",
"maxIdleTime": "最大アイドル時間 (秒)",
"authTimeout": "認証タイムアウト (秒)",
"maxUdpRelayPacketSize": "最大 UDP パケットサイズ",
"sni": "SNI",
"sniHint": "Server Name Indication の上書き"
"sniHint": "クライアント設定の TLS サーバー名。",
"sniRequired": "証明書パスの自動入力にはドメインを指定してください。"
},
"tun": {
"userLevel": "ユーザーレベル"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Renovar no dia",
"renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
"renewsUsed": "Renovações usadas",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Controle de congestionamento",
"congestionControlHint": "Algoritmo do servidor e cliente. Alterações no servidor valem após reconectar.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "Modo de retransmissão UDP",
"udpRelayModeHint": "Modo UDP do cliente. O servidor aceita ambos.",
"zeroRttHandshake": "Handshake 0-RTT",
"logLevel": "Nível de registro",
"logLevelHint": "Status online, último acesso e \"iniciar após o primeiro uso\" são lidos das linhas Info do sidecar; Warn ou Error os desativam para este inbound.",
"logLevelHint": "Nível mínimo de log desta entrada.",
"maxIdleTime": "Tempo máx. ocioso (s)",
"authTimeout": "Tempo limite de autenticação (s)",
"maxUdpRelayPacketSize": "Tamanho máx. do pacote UDP",
"sni": "SNI",
"sniHint": "Substituição do Server Name Indication"
"sniHint": "Nome do servidor TLS no perfil do cliente.",
"sniRequired": "Informe um domínio para preencher os caminhos do certificado."
},
"tun": {
"userLevel": "Nível do Usuário"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Продлевать числа",
"renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
"renewsUsed": "Продлений израсходовано",
"tuicTotalGBDesc": "TUIC не поддерживает персональные квоты трафика; задайте лимит на уровне инбаунда.",
"bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Контроль перегрузок",
"congestionControlHint": "Алгоритм сервера и клиента. На сервере меняется после переподключения.",
"profileOptions": "Параметры клиентского профиля",
"alpn": "ALPN",
"udpRelayMode": "Режим ретрансляции UDP",
"udpRelayModeHint": "Режим UDP для клиента. Сервер поддерживает оба.",
"zeroRttHandshake": "0-RTT рукопожатие",
"logLevel": "Уровень логирования",
"logLevelHint": "Статус онлайн, «последний раз онлайн» и «старт после первого использования» читаются из строк Info сайдкара; Warn или Error отключают их для этого инбаунда.",
"logLevelHint": "Минимальный уровень сообщений в логах этого инбаунда.",
"maxIdleTime": "Макс. время простоя (сек)",
"authTimeout": "Таймаут аутентификации (сек)",
"maxUdpRelayPacketSize": "Макс. размер пакета UDP",
"sni": "SNI",
"sniHint": "Переопределение Server Name Indication"
"sniHint": "Имя сервера для TLS в клиентском профиле.",
"sniRequired": "Укажите домен для автозаполнения."
},
"tun": {
"userLevel": "Уровень пользователя"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Yenileme günü",
"renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
"renewsUsed": "Kullanılan yenileme",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Tıkanıklık Kontrolü",
"congestionControlHint": "Sunucu ve istemci algoritması. Sunucu değişiklikleri yeniden bağlanınca uygulanır.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "UDP İletim Modu",
"udpRelayModeHint": "İstemci UDP modu. Sunucu her ikisini de destekler.",
"zeroRttHandshake": "0-RTT El Sıkışması",
"logLevel": "Günlük Seviyesi",
"logLevelHint": "Çevrimiçi durumu, son görülme ve \"ilk kullanımdan sonra başlat\" sidecar'ın Info satırlarından okunur; Warn veya Error bunları bu inbound için kapatır.",
"logLevelHint": "Bu gelen bağlantı için en düşük günlük seviyesi.",
"maxIdleTime": "Maksimum Boşta Kalma Süresi (sn)",
"authTimeout": "Kimlik Doğrulama Zaman Aşımı (sn)",
"maxUdpRelayPacketSize": "Maksimum UDP Paket Boyutu",
"sni": "SNI",
"sniHint": "Sunucu Adı Belirtimi (SNI) geçersiz kılma"
"sniHint": "İstemci profilindeki TLS sunucu adı.",
"sniRequired": "Sertifika yollarını otomatik doldurmak için alan adı girin."
},
"tun": {
"userLevel": "Kullanıcı Seviyesi"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Подовжувати числа",
"renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
"renewsUsed": "Подовжень витрачено",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Контроль перевантаження",
"congestionControlHint": "Алгоритм сервера й клієнта. На сервері змінюється після перепідключення.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "Режим ретрансляції UDP",
"udpRelayModeHint": "Режим UDP для клієнта. Сервер підтримує обидва.",
"zeroRttHandshake": "0-RTT рукостискання",
"logLevel": "Рівень журналювання",
"logLevelHint": "Статус онлайн, «востаннє онлайн» і «старт після першого використання» читаються з рядків Info сайдкара; Warn або Error вимикають їх для цього інбаунда.",
"logLevelHint": "Мінімальний рівень повідомлень у логах цього інбаунду.",
"maxIdleTime": "Макс. час простою (сек)",
"authTimeout": "Таймаут автентифікації (сек)",
"maxUdpRelayPacketSize": "Макс. розмір пакета UDP",
"sni": "SNI",
"sniHint": "Перевизначення Server Name Indication"
"sniHint": "Ім’я сервера для TLS у профілі клієнта.",
"sniRequired": "Укажіть домен для автозаповнення."
},
"tun": {
"userLevel": "Рівень користувача"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "Gia hạn vào ngày",
"renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
"renewsUsed": "Số lần gia hạn đã dùng",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto."
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "Kiểm soát tắc nghẽn",
"congestionControlHint": "Thuật toán máy chủ và máy khách. Thay đổi trên máy chủ có hiệu lực khi kết nối lại.",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "Chế độ chuyển tiếp UDP",
"udpRelayModeHint": "Chế độ UDP của máy khách. Máy chủ hỗ trợ cả hai.",
"zeroRttHandshake": "Bắt tay 0-RTT",
"logLevel": "Mức ghi nhật ký",
"logLevelHint": "Trạng thái trực tuyến, lần trực tuyến cuối và \"bắt đầu sau lần dùng đầu tiên\" được đọc từ các dòng Info của sidecar; Warn hoặc Error sẽ tắt chúng cho inbound này.",
"logLevelHint": "Mức nhật ký tối thiểu cho inbound này.",
"maxIdleTime": "Thời gian nhàn rỗi tối đa (giây)",
"authTimeout": "Thời gian chờ xác thực (giây)",
"maxUdpRelayPacketSize": "Kích thước gói UDP tối đa",
"sni": "SNI",
"sniHint": "Ghi đè Server Name Indication"
"sniHint": "Tên máy chủ TLS trong cấu hình máy khách.",
"sniRequired": "Nhập tên miền để tự điền đường dẫn chứng chỉ."
},
"tun": {
"userLevel": "Mức Người Dùng"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "按日期续期",
"renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
"renewsUsed": "已用续期次数",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。"
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "拥塞控制算法",
"congestionControlHint": "服务器和客户端算法。服务器更改在重新连接后生效。",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "UDP 转发模式",
"udpRelayModeHint": "客户端 UDP 模式。服务器支持两种模式。",
"zeroRttHandshake": "0-RTT 握手",
"logLevel": "日志级别",
"logLevelHint": "在线状态、最后在线时间和“首次使用后开始”来自 sidecar 的 Info 日志行;选择 Warn 或 Error 会使此入站失去这些功能。",
"logLevelHint": "此入站的最低日志级别。",
"maxIdleTime": "最大空闲时间 (秒)",
"authTimeout": "认证超时时间 (秒)",
"maxUdpRelayPacketSize": "最大 UDP 数据包大小",
"sni": "SNI",
"sniHint": "服务器名称指示 (SNI) 覆盖"
"sniHint": "客户端配置中的 TLS 服务器名称。",
"sniRequired": "请输入域名以自动填充证书路径。"
},
"tun": {
"userLevel": "用户级别"
+6 -3
View File
@@ -966,7 +966,6 @@
"renewOnDay": "按日期續期",
"renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
"renewsUsed": "已用續期次數",
"tuicTotalGBDesc": "TUIC does not support per-client traffic limits; set traffic limit on the inbound instead.",
"bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。"
},
"groups": {
@@ -2138,16 +2137,20 @@
},
"tuic": {
"congestionControl": "壅塞控制演算法",
"congestionControlHint": "伺服器與用戶端演算法。伺服器變更於重新連線後生效。",
"profileOptions": "Client profile options",
"alpn": "ALPN",
"udpRelayMode": "UDP 轉發模式",
"udpRelayModeHint": "用戶端 UDP 模式。伺服器支援兩種模式。",
"zeroRttHandshake": "0-RTT 握手",
"logLevel": "日誌層級",
"logLevelHint": "線上狀態、最後上線時間和「首次使用後開始」來自 sidecar 的 Info 日誌行;選擇 Warn 或 Error 會使此入站失去這些功能。",
"logLevelHint": "此入站的最低記錄級別。",
"maxIdleTime": "最大閒置時間 (秒)",
"authTimeout": "認證逾時時間 (秒)",
"maxUdpRelayPacketSize": "最大 UDP 封包大小",
"sni": "SNI",
"sniHint": "伺服器名稱指示 (SNI) 覆蓋"
"sniHint": "用戶端設定檔中的 TLS 伺服器名稱。",
"sniRequired": "請輸入網域以自動填入憑證路徑。"
},
"tun": {
"userLevel": "用戶級別"
+23 -15
View File
@@ -791,15 +791,30 @@ func (s *Server) StopPanelOnly() error {
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
s.cancel()
if stopXray {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
amneziawgnet.GetManager().StopAll()
tuic.GetManager().StopAll()
amneziawgnet.GetOutboundManager().StopAll()
var err1 error
var err2 error
if s.httpServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
err1 = s.httpServer.Shutdown(shutdownCtx)
shutdownCancel()
}
if s.cron != nil {
s.cron.Stop()
<-s.cron.Stop().Done()
}
if stopXray {
tuic.GetManager().StopAll()
if err := job.NewTuicJob().FlushStoppedTraffic(); err != nil {
logger.Warning("persist final TUIC traffic on shutdown failed:", err)
err2 = err
}
mtproto.GetManager().StopAll()
amneziawgnet.GetManager().StopAll()
amneziawgnet.GetOutboundManager().StopAll()
}
if stopXray {
if err := s.xrayService.StopXray(); err != nil {
err2 = common.Combine(err2, err)
}
}
if s.bus != nil {
s.bus.Stop()
@@ -820,15 +835,8 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
if s.wsHub != nil {
s.wsHub.Stop()
}
var err1 error
var err2 error
if s.httpServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
err1 = s.httpServer.Shutdown(shutdownCtx)
}
if s.listener != nil {
err2 = s.listener.Close()
err1 = common.Combine(err1, s.listener.Close())
}
return common.Combine(err1, err2)
}
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"path/filepath"
"strings"
"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/tuic"
"github.com/mhsanaei/3x-ui/v3/internal/web/job"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestGracefulShutdownPersistsFinalTuicTraffic(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate certificate key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certificate := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
t.Fatalf("marshal certificate key: %v", err)
}
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
const (
inboundID = 18002
email = "shutdown-tuic@x"
)
inbound := &model.Inbound{
Id: inboundID, Tag: "tuic-shutdown-traffic", Protocol: model.TUIC,
Enable: true, Listen: "127.0.0.1", Port: 0,
Settings: fmt.Sprintf(`{"certificate":%q,"private_key":%q,"clients":[{"uuid":"a0000000-0000-0000-0000-000000000022","password":"p","email":%q,"enable":true}]}`, certificate, privatePEM, email),
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("create TUIC inbound: %v", err)
}
if err := database.GetDB().Create(&xray.ClientTraffic{InboundId: inboundID, Email: email, Enable: true}).Error; err != nil {
t.Fatalf("create client traffic: %v", err)
}
manager := tuic.GetManager()
manager.StopAll()
manager.CollectAllTraffic()
t.Cleanup(manager.StopAll)
job.NewTuicJob().Run()
if !manager.AddTestTraffic(inboundID, email, 100, 200) {
t.Fatal("TUIC listener did not start")
}
if err := NewServer().Stop(); err != nil && !strings.Contains(err.Error(), "xray is not running") {
t.Fatalf("graceful server shutdown: %v", err)
}
var got xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&got).Error; err != nil {
t.Fatalf("load persisted client traffic: %v", err)
}
if got.Up != 100 || got.Down != 200 {
t.Fatalf("shutdown persisted traffic (%d,%d), want (100,200)", got.Up, got.Down)
}
_, pending := manager.CollectAllTraffic()
if len(pending) != 0 {
t.Fatalf("final TUIC traffic remains only in manager memory: %+v", pending)
}
}