Files
3x-ui/internal/web/service/tuic_relay_reconcile.go
T
Egor 207c705300 fix(tuic): preserve client identity for Xray user routing (#6875)
TUIC traffic reached Xray through a loopback SOCKS relay that took no login,
so Xray never learned which client a connection belonged to and user routing
rules never matched TUIC (#6874). The relay now carries one account per client
email, so per-user rules match on TCP and both UDP modes; admission stays with
the native TUIC server.

Adding, removing or renaming a TUIC client swaps only that inbound's relay
instead of restarting Xray: new sessions wait behind a bounded gate while live
streams and UDP associations continue, accounts are verified after the swap and
rolled back on failure, one failing relay no longer strands the others or blocks
unrelated live changes, and emails over SOCKS5's 255-byte limit are excluded
with a warning.
2026-10-24 06:20:39 +02:00

287 lines
8.3 KiB
Go

package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// Renames and port swaps share one rollback boundary; independent inbounds do not.
func groupTuicRelayChanges(changes []xray.InboundReplacement) [][]xray.InboundReplacement {
parent := make([]int, len(changes))
for i := range parent {
parent[i] = i
}
root := func(i int) int {
for parent[i] != i {
i = parent[i]
}
return i
}
ports, owners := make(map[int]int), make(map[int]int)
tags := make(map[string]int)
for i, change := range changes {
for _, inbound := range []*xray.InboundConfig{change.Previous, change.Next} {
if inbound == nil {
continue
}
if j, ok := ports[inbound.Port]; ok {
parent[root(i)] = root(j)
}
ports[inbound.Port] = i
if j, ok := tags[inbound.Tag]; ok {
parent[root(i)] = root(j)
}
tags[inbound.Tag] = i
if inbound.TuicInboundID != 0 {
if j, ok := owners[inbound.TuicInboundID]; ok {
parent[root(i)] = root(j)
}
owners[inbound.TuicInboundID] = i
}
}
}
var groups [][]xray.InboundReplacement
indices := make(map[int]int)
for i, change := range changes {
key := root(i)
index, exists := indices[key]
if !exists {
index = len(groups)
indices[key] = index
groups = append(groups, nil)
}
groups[index] = append(groups[index], change)
}
return groups
}
func relayBindsOverlap(a, b xray.InboundConfig) bool {
return a.Tag == b.Tag || (a.Port > 0 && a.Port == b.Port &&
len(rawBindConflicts(&xray.Config{InboundConfigs: []xray.InboundConfig{a, b}})) > 0)
}
func tuicRequiresOrdinaryRemoval(old *xray.Config, changes []xray.InboundReplacement) bool {
for _, change := range changes {
if change.Next == nil {
continue
}
for _, inbound := range old.InboundConfigs {
if !inbound.TuicRelay && relayBindsOverlap(inbound, *change.Next) {
return true
}
}
}
return false
}
// A failed relay retains its last verified config. Dependent tag/port handoffs
// and their routing/outbounds wait too; unrelated changes can still converge.
func configWithDeferredTuic(old, desired *xray.Config, failed []xray.InboundReplacement) *xray.Config {
effective := *desired
previous, pendingTags := make(map[string]xray.InboundConfig), make(map[string]bool)
for _, change := range failed {
if change.Previous != nil {
previous[change.Previous.Tag] = *change.Previous
}
if change.Next != nil {
pendingTags[change.Next.Tag] = true
}
}
changedTags := false
dependentTags := make(map[string]bool)
for _, change := range failed {
if change.Previous == nil {
continue
}
old := change.Previous
nextTag := ""
for _, other := range failed {
if next := other.Next; next != nil && ((old.TuicInboundID != 0 && old.TuicInboundID == next.TuicInboundID) || (old.TuicInboundID == 0 && old.Port == next.Port)) {
nextTag = next.Tag
break
}
}
if nextTag != old.Tag {
changedTags = true
dependentTags[old.Tag] = true
if nextTag != "" {
dependentTags[nextTag] = true
}
}
}
effective.InboundConfigs = make([]xray.InboundConfig, 0, len(desired.InboundConfigs))
seen := make(map[string]bool)
for _, inbound := range desired.InboundConfigs {
if kept, ok := previous[inbound.Tag]; ok {
inbound = kept
} else if inbound.TuicRelay && pendingTags[inbound.Tag] {
continue
} else if !inbound.TuicRelay {
blocked := false
for _, kept := range previous {
if relayBindsOverlap(inbound, kept) {
blocked = true
break
}
}
if blocked {
index := slices.IndexFunc(old.InboundConfigs, func(candidate xray.InboundConfig) bool { return candidate.Tag == inbound.Tag })
if index < 0 {
continue
}
inbound = old.InboundConfigs[index]
changedTags = true
}
}
effective.InboundConfigs = append(effective.InboundConfigs, inbound)
seen[inbound.Tag] = true
}
for _, inbound := range old.InboundConfigs {
if _, ok := previous[inbound.Tag]; ok && !seen[inbound.Tag] {
effective.InboundConfigs = append(effective.InboundConfigs, inbound)
}
}
if changedTags && routingRulesChanged(old.RouterConfig, desired.RouterConfig, dependentTags, false) {
effective.RouterConfig = old.RouterConfig
effective.OutboundConfigs = old.OutboundConfigs
kept := effective.InboundConfigs[:0]
for _, inbound := range effective.InboundConfigs {
if !inbound.TuicRelay && routingRulesChanged(old.RouterConfig, desired.RouterConfig, map[string]bool{inbound.Tag: true}, true) {
index := slices.IndexFunc(old.InboundConfigs, func(candidate xray.InboundConfig) bool { return candidate.Tag == inbound.Tag })
if index < 0 {
continue
}
inbound = old.InboundConfigs[index]
}
kept = append(kept, inbound)
}
effective.InboundConfigs = kept
}
return &effective
}
func routingRulesChanged(old, desired []byte, tags map[string]bool, includeGlobal bool) bool {
matchingRules := func(raw []byte) ([]byte, error) {
var router struct {
Rules []map[string]any `json:"rules"`
}
if err := json.Unmarshal(raw, &router); err != nil {
return nil, err
}
var matched []map[string]any
for _, rule := range router.Rules {
inbounds, _ := rule["inboundTag"].([]any)
if includeGlobal && len(inbounds) == 0 {
matched = append(matched, rule)
continue
}
for _, inbound := range inbounds {
tag, _ := inbound.(string)
if tags[tag] {
matched = append(matched, rule)
break
}
}
}
return json.Marshal(matched)
}
before, err := matchingRules(old)
if err != nil {
return true
}
after, err := matchingRules(desired)
return err != nil || !bytes.Equal(before, after)
}
type stagedTuicRelay struct {
transaction *tuicRelayTransaction
changes []xray.InboundReplacement
changed bool
}
type tuicHotReconcile struct {
effective *xray.Config
staged []*stagedTuicRelay
}
func (r *tuicHotReconcile) finish() {
for _, group := range r.staged {
group.transaction.finish()
}
}
func (r *tuicHotReconcile) rollback(api *xray.XrayAPI) error {
var failures error
for _, group := range r.staged {
if group.changed {
failures = errors.Join(failures, group.transaction.rollback(api, group.changes))
group.changed = false
}
}
return failures
}
func reconcileTuicHotConfig(api *xray.XrayAPI, old, desired *xray.Config, changes []xray.InboundReplacement) (*tuicHotReconcile, error) {
result := &tuicHotReconcile{}
var failures error
var deferred []xray.InboundReplacement
for _, group := range groupTuicRelayChanges(changes) {
transaction, err := stageTuicRelayGroup(api, group)
if transaction != nil {
result.staged = append(result.staged, &stagedTuicRelay{transaction: transaction, changes: group, changed: err == nil})
}
if err != nil {
failures = errors.Join(failures, err)
deferred = append(deferred, group...)
}
}
effective := configWithDeferredTuic(old, desired, deferred)
if !bytes.Equal(effective.RouterConfig, desired.RouterConfig) {
for _, group := range result.staged {
if !group.changed {
continue
}
tags := make(map[string]bool)
for _, change := range group.changes {
for _, inbound := range []*xray.InboundConfig{change.Previous, change.Next} {
if inbound != nil {
tags[inbound.Tag] = true
}
}
}
if routingRulesChanged(old.RouterConfig, desired.RouterConfig, tags, true) {
failures = errors.Join(failures, errors.New("TUIC relay update waits for dependent routing"), group.transaction.rollback(api, group.changes))
group.changed = false
deferred = append(deferred, group.changes...)
}
}
}
result.effective = configWithDeferredTuic(old, desired, deferred)
// Repair unchanged relays too: a failed rollback or cold-start check does
// not necessarily change the desired JSON on the next reconciliation.
unchanged := *old
unchanged.InboundConfigs = nil
for _, inbound := range old.InboundConfigs {
changed := false
for _, change := range changes {
for _, candidate := range []*xray.InboundConfig{change.Previous, change.Next} {
if candidate != nil && (candidate.Port == inbound.Port || candidate.Tag == inbound.Tag) {
changed = true
}
}
}
if !changed {
unchanged.InboundConfigs = append(unchanged.InboundConfigs, inbound)
}
}
if err := applyTuicRelayReplacements(api, unavailableTuicRepairs(&unchanged)); err != nil {
failures = errors.Join(failures, fmt.Errorf("repair TUIC relay: %w", err))
}
return result, failures
}