Files
3x-ui/internal/web/service/client_link.go
T
Egor 0054e671f8 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>
2026-10-03 02:56:02 +02:00

462 lines
14 KiB
Go

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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// applyClientRecordMerge merges incoming client-record fields onto row using the
// same rules everywhere a client record is persisted: scalar quota / lifecycle /
// subscription fields are applied unconditionally (so clearing them takes
// effect), while credentials and identifiers are only overwritten when the
// incoming value is non-empty (so a partial update preserves the stored UUID /
// password / keys). CreatedAt keeps the earliest known value. Email, UpdatedAt,
// and the Id primary key are intentionally not touched here — callers handle
// those separately. Shared by SyncInbound (per-inbound persistence) and Update
// (the no-attached-inbound fallback) so the two paths cannot diverge.
func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecord) {
if incoming.UUID != "" {
row.UUID = incoming.UUID
}
if incoming.Password != "" {
row.Password = incoming.Password
}
if incoming.Auth != "" {
row.Auth = incoming.Auth
}
if incoming.Secret != "" {
row.Secret = incoming.Secret
}
if incoming.AdTag != "" {
row.AdTag = incoming.AdTag
}
row.Flow = incoming.Flow
if incoming.Security != "" {
row.Security = incoming.Security
}
if incoming.Reverse != "" {
row.Reverse = incoming.Reverse
}
if incoming.PrivateKey != "" {
row.PrivateKey = incoming.PrivateKey
}
if incoming.PublicKey != "" {
row.PublicKey = incoming.PublicKey
}
if incoming.AllowedIPs != "" {
row.AllowedIPs = incoming.AllowedIPs
}
row.PreSharedKey = incoming.PreSharedKey
row.KeepAlive = incoming.KeepAlive
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
row.TgID = incoming.TgID
if incoming.Group != "" {
row.Group = incoming.Group
}
row.Comment = incoming.Comment
row.Reset = incoming.Reset
row.ResetDay = incoming.ResetDay
row.ResetWeekday = incoming.ResetWeekday
row.ResetMax = incoming.ResetMax
// Guarded like Group and AdTag: a node snapshot rebuilt from settings that
// predate the cycle would otherwise silently erase it.
if incoming.TrafficReset != "" {
row.TrafficReset = incoming.TrafficReset
}
if incoming.TrafficResetDay > 0 {
row.TrafficResetDay = incoming.TrafficResetDay
}
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt
}
}
// SyncInbound makes the inbound's client records and links match clients
// exactly: links for clients no longer in the set are removed.
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
return s.syncInboundClients(tx, inboundId, clients, nil, true)
}
// ApplyInboundClientDelta persists only the clients an edit actually changed
// plus the emails it detached, leaving every other link on the inbound alone —
// the whole point being that a one-client edit must not rewrite the inbound's
// entire membership set (#6252).
func (s *ClientService) ApplyInboundClientDelta(tx *gorm.DB, inboundId int, changed []model.Client, detachEmails []string) error {
return s.syncInboundClients(tx, inboundId, changed, detachEmails, false)
}
func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients []model.Client, detachEmails []string, prune bool) error {
if err := validateClientsRenewal(clients); err != nil {
return err
}
if tx == nil {
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 {
email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
emails = append(emails, email)
}
existing := make(map[string]*model.ClientRecord, len(emails))
const selectChunk = 400
for start := 0; start < len(emails); start += selectChunk {
end := min(start+selectChunk, len(emails))
var rows []model.ClientRecord
if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
return err
}
for i := range rows {
r := rows[i]
existing[r.Email] = &r
}
}
idByEmail := make(map[string]int, len(emails))
pending := make(map[string]*model.ClientRecord, len(emails))
toCreate := make([]*model.ClientRecord, 0, len(emails))
for i := range clients {
email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
incoming := clients[i].ToRecord()
// ToRecord copies the raw email; store the trimmed key this function
// looks up by, or a padded email is inserted and never found again.
incoming.Email = email
row, ok := existing[email]
if !ok {
if _, dup := pending[email]; !dup {
pending[email] = incoming
toCreate = append(toCreate, incoming)
}
continue
}
before := *row
applyClientRecordMerge(row, incoming)
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt
idByEmail[email] = row.Id
if *row == before {
continue
}
if err := tx.Save(row).Error; err != nil {
return err
}
if err := tx.Model(&model.ClientRecord{}).
Where("id = ?", row.Id).
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
return err
}
}
if len(toCreate) > 0 {
// Capture enable before Create: gorm default:true drops explicit false (#6478).
// Restate disabled rows after CreateInBatches.
wantEnable := make([]bool, len(toCreate))
for i, rec := range toCreate {
wantEnable[i] = rec.Enable
}
if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
return err
}
disabledIDs := make([]int, 0)
for i, rec := range toCreate {
idByEmail[rec.Email] = rec.Id
if !wantEnable[i] {
disabledIDs = append(disabledIDs, rec.Id)
}
}
for _, batch := range chunkInts(disabledIDs, sqlInChunk) {
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", batch).
UpdateColumn("enable", false).Error; err != nil {
return err
}
}
}
wantedFlow := make(map[int]string, len(clients))
wantedIds := make([]int, 0, len(clients))
for i := range clients {
email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
id, ok := idByEmail[email]
if !ok {
continue
}
if _, dup := wantedFlow[id]; dup {
continue
}
wantedFlow[id] = clients[i].Flow
wantedIds = append(wantedIds, id)
}
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
// also removes links absent from wantedFlow, which only a full sync may do.
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error {
var current []model.ClientInbound
if prune {
if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil {
return err
}
} else {
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
var rows []model.ClientInbound
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
return err
}
current = append(current, rows...)
}
}
var toDelete []int
toUpdate := make(map[string][]int)
have := make(map[int]struct{}, len(current))
for _, link := range current {
have[link.ClientId] = struct{}{}
flow, keep := wantedFlow[link.ClientId]
if !keep {
if prune {
toDelete = append(toDelete, link.ClientId)
}
continue
}
// Plain compare, not non-empty-wins: clearing a flow must persist "".
if flow != link.FlowOverride {
toUpdate[flow] = append(toUpdate[flow], link.ClientId)
}
}
if len(detachEmails) > 0 {
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
var ids []int
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if _, keep := wantedFlow[id]; !keep {
toDelete = append(toDelete, id)
}
}
}
}
toInsert := make([]model.ClientInbound, 0, len(wantedIds))
for _, id := range wantedIds {
if _, exists := have[id]; exists {
continue
}
toInsert = append(toInsert, model.ClientInbound{
ClientId: id,
InboundId: inboundId,
FlowOverride: wantedFlow[id],
})
}
for _, batch := range chunkInts(toDelete, sqlInChunk) {
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
}
for flow, ids := range toUpdate {
for _, batch := range chunkInts(ids, sqlInChunk) {
if err := tx.Model(&model.ClientInbound{}).
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
Update("flow_override", flow).Error; err != nil {
return err
}
}
}
if len(toInsert) > 0 {
// The delete this replaced also serialized concurrent syncs of one
// inbound; without the clause a racing node poll aborts its whole tx.
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
}).CreateInBatches(toInsert, 200).Error; err != nil {
return err
}
}
return nil
}
func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
if tx == nil {
tx = database.GetDB()
}
return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
}
func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
if tx == nil {
tx = database.GetDB()
}
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", inboundId).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]model.Client, 0, len(rows))
for i := range rows {
c := rows[i].ToClient()
c.Flow = rows[i].FlowOverride
out = append(out, *c)
}
return out, nil
}
// ListForInboundBySubId is ListForInbound narrowed to one subscription id —
// both filter columns are indexed, so the subscription server resolves a
// subscriber's clients without touching the inbound's settings JSON.
func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId string) ([]model.Client, error) {
if tx == nil {
tx = database.GetDB()
}
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ? AND clients.sub_id = ?", inboundId, subId).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]model.Client, 0, len(rows))
for i := range rows {
c := rows[i].ToClient()
c.Flow = rows[i].FlowOverride
out = append(out, *c)
}
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
}