mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-27 16:25:31 +03:00
207c705300
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.
287 lines
8.3 KiB
Go
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
|
|
}
|