mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-09 22:55:00 +03:00
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.
This commit is contained in:
@@ -10,7 +10,7 @@ and custom congestion control algorithms to maintain stable connections over los
|
|||||||
unstable networks.
|
unstable networks.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and zero-downtime hot user updates without restarting the port.
|
TUIC runs as an **in-process native Go server** inside 3x-ui. Decrypted traffic is bridged into Xray-core via a loopback SOCKS5 tunnel, enabling full Xray routing rules, cascading outbounds (e.g. TUIC → VLESS / WARP), per-client traffic quotas (`totalGB`), and in-memory client management.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Key settings
|
## Key settings
|
||||||
@@ -55,7 +55,7 @@ The panel fills recommended defaults (`bbr`, `h3`, `native` UDP relay). Adjust t
|
|||||||
<Step>
|
<Step>
|
||||||
### Add clients
|
### Add clients
|
||||||
|
|
||||||
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. The panel automatically generates secure random credentials when creating clients.
|
Each client requires an **Email** identifier, a **UUID** (token), and a **Password**. TUIC email identifiers must contain 1–255 UTF-8 bytes. Existing clients with invalid identifiers are excluded from TUIC service with a warning; shorten their identifiers to restore access. The panel automatically generates secure random credentials when creating clients.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
@@ -103,10 +103,10 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
|||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
||||||
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
|
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. The client **Email** is preserved for `user` rules on TCP and both UDP modes. Inbound tags (`in-<port>-udp`), domain/IP rules, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.) are supported. Put specific `inboundTag` + `user` rules before broader `user` rules; Xray uses the first matching rule.
|
||||||
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
||||||
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
|
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay. A separate relay policy disables Xray's per-user traffic and loopback online-IP counters; native TUIC remains the source for client accounting, speed, and activity.
|
||||||
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
|
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
|
||||||
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
|
- **Client updates**: Native TUIC enforces client enable/disable, quotas, and expiration; resetting a quota restores access without restarting Xray. Adding, removing, or renaming clients replaces only that inbound's internal SOCKS relay. Accounts are validated before replacement and checked again against the running relay afterward. A failed update restores the previous relay and schedules a retry. Failures are isolated to related relays: healthy TUIC inbounds and independent Xray updates continue to apply. Routing changes that depend on a failed tag change wait for that relay to recover. Related client and inbound changes also wait; new TUIC sessions are admitted only after their routing policy is applied. New relay connections wait up to 10 seconds while replacement or rollback is in progress; existing TCP streams and UDP associations continue running. New UDP associations use a bounded packet queue so they do not block existing flows on the same QUIC connection. Waiting is canceled on disconnect. Queue overflow or an unavailable relay can still reject a new request; other protocols keep running. TUIC password-only changes and client-profile edits do not change relay accounts.
|
||||||
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ icon: Zap
|
|||||||
и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
|
и настраиваемый контроль перегрузок для поддержания стабильной связи на сетях с потерями пакетов.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и горячего обновления пользователей без обрыва соединений.
|
TUIC работает как **встроенный нативный Go-сервер** прямо внутри процесса 3x-ui. Расшифрованный трафик направляется в ядро Xray-core через локальный SOCKS5-мост, что обеспечивает полную поддержку правил маршрутизации Xray, каскадирования (например, TUIC → VLESS / WARP), персональных квот клиентов (`totalGB`) и управления клиентами в памяти.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Ключевые параметры
|
## Ключевые параметры
|
||||||
@@ -54,7 +54,7 @@ icon: Zap
|
|||||||
<Step>
|
<Step>
|
||||||
### Добавьте клиентов
|
### Добавьте клиентов
|
||||||
|
|
||||||
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Панель автоматически генерирует надёжные случайные данные при создании клиента.
|
Для каждого клиента требуется **Email** (идентификатор), **UUID** (токен) и **Пароль**. Идентификатор Email для TUIC должен занимать 1–255 байт UTF-8. Старые клиенты с некорректными идентификаторами исключаются из обслуживания TUIC с предупреждением; для восстановления доступа сократите идентификатор. Панель автоматически генерирует надёжные случайные данные при создании клиента.
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step>
|
<Step>
|
||||||
@@ -102,10 +102,10 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
|||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
||||||
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
|
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Идентификатор **Email** клиента передаётся в правила `user` для TCP и обоих режимов UDP. Поддерживаются теги инбаундов (`in-<port>-udp`), правила по доменам/IP и каскадирование через любой outbound. Ставьте специфичные правила `inboundTag` + `user` выше общих правил `user`: Xray выбирает первое совпадение.
|
||||||
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
||||||
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
|
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay. Отдельная policy отключает в relay пользовательские счётчики трафика и учёт loopback-IP: источником клиентского трафика, скорости и активности остаётся нативный TUIC.
|
||||||
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
|
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
|
||||||
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
|
- **Обновление клиентов**: Включённость, квоты и срок проверяет нативный TUIC; сброс квоты восстанавливает доступ без перезапуска Xray. Добавление, удаление или переименование клиента заменяет только внутренний SOCKS relay его inbound. Аккаунты проверяются до замены и повторно сверяются с работающим relay после неё. При ошибке восстанавливается прежний relay и планируется повторная попытка. Ошибки изолированы в группе связанных relay: исправные TUIC inbound и независимые изменения Xray продолжают применяться. Изменения маршрутизации, зависящие от неудачной смены тега, ожидают восстановления этого relay. Связанные изменения клиентов и inbound тоже откладываются; новые TUIC-сессии допускаются после применения их правил маршрутизации. Новые relay-соединения ожидают до 10 секунд во время замены или отката; открытые TCP-потоки и UDP-ассоциации продолжают работать. Пакеты новых UDP-ассоциаций помещаются в ограниченную очередь, не блокируя действующие потоки того же QUIC-соединения. При отключении клиента ожидание отменяется. Переполнение очереди или недоступность relay всё ещё могут привести к отказу нового запроса; остальные протоколы продолжают работать. Изменение только TUIC-пароля или параметров клиентского профиля не меняет relay-аккаунты.
|
||||||
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
|||||||
|
|
||||||
func (m *Manager) startLocked(inst Instance) (*Server, error) {
|
func (m *Manager) startLocked(inst Instance) (*Server, error) {
|
||||||
relay := &SocksRelay{
|
relay := &SocksRelay{
|
||||||
Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
|
Gate: ManagedRelayGate(SOCKSPortForInbound(inst.Id)),
|
||||||
Password: SocksPassword(),
|
InboundID: inst.Id,
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
|
||||||
|
Password: SocksPassword(),
|
||||||
}
|
}
|
||||||
server, err := NewServer(inst, relay)
|
server, err := NewServer(inst, relay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ func audit3StartSocksForManager(t *testing.T, expectedUser, expectedPass string,
|
|||||||
t.Fatalf("failed to listen: %v", err)
|
t.Fatalf("failed to listen: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update, err := ManagedRelayGate(SOCKSPortForInbound(inboundID)).BeginUpdate(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
update.Finish(inboundID, []string{expectedUser}, true)
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ func TestAudit3ManagerStopMustInterruptIdleTCPRelay(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
update, err := ManagedRelayGate(SOCKSPortForInbound(inboundID)).BeginUpdate(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
update.Finish(inboundID, []string{"close-idle@audit3"}, true)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
defer close(release)
|
defer close(release)
|
||||||
peerFIN := make(chan struct{})
|
peerFIN := make(chan struct{})
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package tuic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
relayOpenTimeout = 10 * time.Second
|
||||||
|
maxPendingRelayOpens = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrRelayQueueFull = errors.New("tuic relay: pending connection limit reached")
|
||||||
|
|
||||||
|
var relayGates [relayPortSlots]atomic.Pointer[RelayGate]
|
||||||
|
|
||||||
|
type RelayGate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
writer chan struct{}
|
||||||
|
changed chan struct{}
|
||||||
|
paused bool
|
||||||
|
ready bool
|
||||||
|
owner int
|
||||||
|
accounts map[string]struct{}
|
||||||
|
active int
|
||||||
|
waiting int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedRelayGate uses the same bounded slots as the loopback relay ports.
|
||||||
|
// The owner check prevents a reused slot from forwarding through a deleted inbound.
|
||||||
|
func ManagedRelayGate(port int) *RelayGate {
|
||||||
|
index := port - SOCKSBasePort - 1
|
||||||
|
if index < 0 || index >= len(relayGates) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if gate := relayGates[index].Load(); gate != nil {
|
||||||
|
return gate
|
||||||
|
}
|
||||||
|
gate := &RelayGate{writer: make(chan struct{}, 1), changed: make(chan struct{})}
|
||||||
|
if relayGates[index].CompareAndSwap(nil, gate) {
|
||||||
|
return gate
|
||||||
|
}
|
||||||
|
return relayGates[index].Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *RelayGate) signalLocked() {
|
||||||
|
close(g.changed)
|
||||||
|
g.changed = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *RelayGate) acquire(ctx context.Context, owner int, email string) (func(), error) {
|
||||||
|
g.mu.Lock()
|
||||||
|
if g.waiting >= maxPendingRelayOpens {
|
||||||
|
g.mu.Unlock()
|
||||||
|
return nil, ErrRelayQueueFull
|
||||||
|
}
|
||||||
|
g.waiting++
|
||||||
|
defer func() {
|
||||||
|
g.waiting--
|
||||||
|
g.mu.Unlock()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, admitted := g.accounts[email]
|
||||||
|
if !g.paused && g.ready && g.owner == owner && admitted {
|
||||||
|
g.active++
|
||||||
|
return func() {
|
||||||
|
g.mu.Lock()
|
||||||
|
g.active--
|
||||||
|
g.signalLocked()
|
||||||
|
g.mu.Unlock()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
changed := g.changed
|
||||||
|
g.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-changed:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayUpdate struct{ gate *RelayGate }
|
||||||
|
|
||||||
|
func (g *RelayGate) ReadyFor(owner int) bool {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
return g.ready && g.owner == owner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *RelayGate) Invalidate(owner int) {
|
||||||
|
g.mu.Lock()
|
||||||
|
g.owner, g.ready, g.accounts = owner, false, nil
|
||||||
|
g.signalLocked()
|
||||||
|
g.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginUpdate blocks new handshakes and drains only local SOCKS authentication.
|
||||||
|
// Existing TCP streams and UDP associations do not hold this gate.
|
||||||
|
func (g *RelayGate) BeginUpdate(ctx context.Context) (*RelayUpdate, error) {
|
||||||
|
select {
|
||||||
|
case g.writer <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
g.paused = true
|
||||||
|
g.signalLocked()
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
g.paused = false
|
||||||
|
g.signalLocked()
|
||||||
|
g.mu.Unlock()
|
||||||
|
<-g.writer
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if g.active == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
changed := g.changed
|
||||||
|
g.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-changed:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
}
|
||||||
|
g.mu.Unlock()
|
||||||
|
return &RelayUpdate{gate: g}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *RelayUpdate) Finish(owner int, emails []string, available bool) {
|
||||||
|
g := u.gate
|
||||||
|
g.mu.Lock()
|
||||||
|
g.owner, g.ready = owner, available
|
||||||
|
g.accounts = make(map[string]struct{}, len(emails))
|
||||||
|
for _, email := range emails {
|
||||||
|
g.accounts[email] = struct{}{}
|
||||||
|
}
|
||||||
|
g.paused = false
|
||||||
|
g.signalLocked()
|
||||||
|
g.mu.Unlock()
|
||||||
|
<-g.writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *RelayUpdate) Cancel() {
|
||||||
|
g := u.gate
|
||||||
|
g.mu.Lock()
|
||||||
|
g.paused = false
|
||||||
|
g.signalLocked()
|
||||||
|
g.mu.Unlock()
|
||||||
|
<-g.writer
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package tuic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRelayGate() *RelayGate {
|
||||||
|
return &RelayGate{writer: make(chan struct{}, 1), changed: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitRelayWaiters(t *testing.T, gate *RelayGate, want int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
gate.mu.Lock()
|
||||||
|
got := gate.waiting
|
||||||
|
gate.mu.Unlock()
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("pending relay opens = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayWaitQueueRejectsOverflowAndCancelsEveryWaiter(t *testing.T) {
|
||||||
|
gate := testRelayGate()
|
||||||
|
relay := &SocksRelay{Gate: gate, InboundID: 17, Addr: "127.0.0.1:1"}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, maxPendingRelayOpens)
|
||||||
|
target := &Address{Type: AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9}
|
||||||
|
for range maxPendingRelayOpens {
|
||||||
|
go func() {
|
||||||
|
conn, err := relay.DialTCP(ctx, "waiting@test", target)
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
waitRelayWaiters(t, gate, maxPendingRelayOpens)
|
||||||
|
if _, err := relay.DialTCP(ctx, "overflow@test", target); !errors.Is(err, ErrRelayQueueFull) {
|
||||||
|
t.Fatalf("overflow error = %v, want %v", err, ErrRelayQueueFull)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
for range maxPendingRelayOpens {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled pending open returned %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("canceled relay open remained blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitRelayWaiters(t, gate, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayUpdateDrainsAuthenticationAndCancellationRestoresAdmission(t *testing.T) {
|
||||||
|
gate := testRelayGate()
|
||||||
|
initial, err := gate.BeginUpdate(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initial.Finish(17, []string{"active@test"}, true)
|
||||||
|
release, err := gate.acquire(context.Background(), 17, "active@test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
updates := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
update, err := gate.BeginUpdate(ctx)
|
||||||
|
if update != nil {
|
||||||
|
update.Cancel()
|
||||||
|
}
|
||||||
|
updates <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-updates:
|
||||||
|
t.Fatalf("relay replacement overtook active authentication: %v", err)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if err := <-updates; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancel update = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
release, err = gate.acquire(ctx, 17, "active@test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canceled update left admission paused: %v", err)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
if _, err := gate.acquire(ctx, 18, "active@test"); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("reused relay slot admitted another inbound: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerCloseCancelsQueuedUDPAssociation(t *testing.T) {
|
||||||
|
gate := testRelayGate()
|
||||||
|
server, client, id, _ := startLifecycleTestServerWithRelay(t, &SocksRelay{
|
||||||
|
Gate: gate, InboundID: 99101, Addr: "127.0.0.1:1", Password: "lifecycle-socks-password",
|
||||||
|
}, "closing@test")
|
||||||
|
_, _ = authenticatedServerConnection(t, server, id)
|
||||||
|
audit3SendPacket(t, client, packetTransportDatagram, 99, 1, "queued")
|
||||||
|
waitRelayWaiters(t, gate, 1)
|
||||||
|
closed := make(chan error, 1)
|
||||||
|
go func() { closed <- server.Close() }()
|
||||||
|
select {
|
||||||
|
case err := <-closed:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("server shutdown waited for relay update timeout")
|
||||||
|
}
|
||||||
|
waitRelayWaiters(t, gate, 0)
|
||||||
|
if bytes := server.pendingUdpBudget.bytes.Load(); bytes != 0 {
|
||||||
|
t.Fatalf("shutdown retained %d bytes of queued UDP packets", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-5
@@ -32,6 +32,7 @@ type Server struct {
|
|||||||
logLevel atomic.Uint32
|
logLevel atomic.Uint32
|
||||||
|
|
||||||
maxUdpRelayPacketSize int
|
maxUdpRelayPacketSize int
|
||||||
|
pendingUdpBudget pendingUdpBudget
|
||||||
|
|
||||||
users *UserRegistry
|
users *UserRegistry
|
||||||
relay *SocksRelay
|
relay *SocksRelay
|
||||||
@@ -355,6 +356,7 @@ func (s *Server) handleConn(conn *quic.Conn) {
|
|||||||
authOnce sync.Once
|
authOnce sync.Once
|
||||||
udpAssociations = newUdpAssociationRegistry(s.maxUdpRelayPacketSize)
|
udpAssociations = newUdpAssociationRegistry(s.maxUdpRelayPacketSize)
|
||||||
)
|
)
|
||||||
|
udpAssociations.pendingBudget = &s.pendingUdpBudget
|
||||||
|
|
||||||
authTimer := time.AfterFunc(s.authTimeout, func() {
|
authTimer := time.AfterFunc(s.authTimeout, func() {
|
||||||
if authState.CompareAndSwap(0, 2) {
|
if authState.CompareAndSwap(0, 2) {
|
||||||
@@ -835,15 +837,35 @@ func (s *Server) forwardUDPPacket(
|
|||||||
udpAssociations *udpAssociationRegistry,
|
udpAssociations *udpAssociationRegistry,
|
||||||
relayWg *sync.WaitGroup,
|
relayWg *sync.WaitGroup,
|
||||||
) {
|
) {
|
||||||
if len(payload) > s.maxUdpRelayPacketSize || !isPacketTarget(target) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if association == nil || len(payload) > s.maxUdpRelayPacketSize || !isPacketTarget(target) {
|
if association == nil || len(payload) > s.maxUdpRelayPacketSize || !isPacketTarget(target) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
association, created, err := udpAssociations.ensureRelay(ctx, assocID, association, user, s.relay)
|
sess, opening, err := udpAssociations.queuePacket(ctx, assocID, association, target, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logLimited(tuicLogWarn, "udp-dial", 30*time.Second, "UDP relay could not be opened: %v", err)
|
s.logLimited(tuicLogWarn, "udp-queue", 30*time.Second, "UDP relay packet could not be queued: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sess != nil {
|
||||||
|
s.sendUDPRequest(user, sess, target, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if opening == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relayWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer relayWg.Done()
|
||||||
|
s.openUDPRelay(ctx, opening, conn, user, assocID, association, udpAssociations, relayWg)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) openUDPRelay(ctx, opening context.Context, conn *quic.Conn, user *User, assocID uint16, association *udpAssociation, udpAssociations *udpAssociationRegistry, relayWg *sync.WaitGroup) {
|
||||||
|
_, created, err := udpAssociations.ensureRelay(opening, assocID, association, user, s.relay)
|
||||||
|
if err != nil {
|
||||||
|
if opening.Err() == nil {
|
||||||
|
s.logLimited(tuicLogWarn, "udp-dial", 30*time.Second, "UDP relay could not be opened: %v", err)
|
||||||
|
}
|
||||||
|
udpAssociations.failPending(association)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess := association.relay
|
sess := association.relay
|
||||||
@@ -855,7 +877,19 @@ func (s *Server) forwardUDPPacket(
|
|||||||
s.relayUDPResponses(ctx, conn, user, assocID, association, udpAssociations, sess)
|
s.relayUDPResponses(ctx, conn, user, assocID, association, udpAssociations, sess)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
for {
|
||||||
|
packet, ok := udpAssociations.nextPending(assocID, association)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
s.sendUDPRequest(user, sess, packet.target, packet.payload)
|
||||||
|
}
|
||||||
|
udpAssociations.releasePendingPacket(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendUDPRequest(user *User, sess *udpRelaySession, target *Address, payload []byte) {
|
||||||
if _, err := sess.relay.Send(target, payload); err != nil {
|
if _, err := sess.relay.Send(target, payload); err != nil {
|
||||||
s.logLimited(tuicLogWarn, "udp-send", 30*time.Second, "UDP relay request failed: %v", err)
|
s.logLimited(tuicLogWarn, "udp-send", 30*time.Second, "UDP relay request failed: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -20,10 +20,19 @@ var ErrUdpPayloadTooLarge = errors.New("tuic socks: UDP packet exceeds the maxim
|
|||||||
|
|
||||||
const maxSocksUdpDatagramSize = 65507
|
const maxSocksUdpDatagramSize = 65507
|
||||||
|
|
||||||
|
func ValidateRelayEmail(email string) error {
|
||||||
|
if len(email) == 0 || len(email) > 255 {
|
||||||
|
return errors.New("TUIC client email must contain 1 to 255 UTF-8 bytes")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SocksRelay describes the loopback SOCKS5 endpoint where decrypted TUIC traffic is forwarded.
|
// SocksRelay describes the loopback SOCKS5 endpoint where decrypted TUIC traffic is forwarded.
|
||||||
type SocksRelay struct {
|
type SocksRelay struct {
|
||||||
Addr string // e.g. "127.0.0.1:63201"
|
Gate *RelayGate
|
||||||
Password string // internal shared password for the SOCKS inbound
|
InboundID int
|
||||||
|
Addr string // e.g. "127.0.0.1:63201"
|
||||||
|
Password string // internal shared password for the SOCKS inbound
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountingConn wraps a net.Conn and tracks bytes read and written atomically.
|
// CountingConn wraps a net.Conn and tracks bytes read and written atomically.
|
||||||
@@ -51,18 +60,12 @@ func (c *CountingConn) Write(p []byte) (int, error) {
|
|||||||
|
|
||||||
// DialTCP establishes a SOCKS5 CONNECT tunnel to the target address on behalf of user.
|
// DialTCP establishes a SOCKS5 CONNECT tunnel to the target address on behalf of user.
|
||||||
func (r *SocksRelay) DialTCP(ctx context.Context, user string, target *Address) (net.Conn, error) {
|
func (r *SocksRelay) DialTCP(ctx context.Context, user string, target *Address) (net.Conn, error) {
|
||||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
conn, err := r.dialAuthenticated(ctx, user)
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", r.Addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tuic socks: dial relay %s: %w", r.Addr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
|
|
||||||
if err := socks5Handshake(conn, user, r.Password); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||||
|
defer stop()
|
||||||
|
|
||||||
// Send SOCKS5 CONNECT request
|
// Send SOCKS5 CONNECT request
|
||||||
req := buildSocks5ConnectRequest(target)
|
req := buildSocks5ConnectRequest(target)
|
||||||
@@ -81,10 +84,43 @@ func (r *SocksRelay) DialTCP(ctx context.Context, user string, target *Address)
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = conn.SetDeadline(time.Time{})
|
_ = conn.SetDeadline(time.Time{})
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *SocksRelay) dialAuthenticated(ctx context.Context, user string) (net.Conn, error) {
|
||||||
|
if r.Gate != nil {
|
||||||
|
waiting, cancel := context.WithTimeout(ctx, relayOpenTimeout)
|
||||||
|
release, err := r.Gate.acquire(waiting, r.InboundID, user)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", r.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tuic socks: dial relay %s: %w", r.Addr, err)
|
||||||
|
}
|
||||||
|
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||||
|
defer stop()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := socks5Handshake(conn, user, r.Password); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildSocks5ConnectRequest(target *Address) []byte {
|
func buildSocks5ConnectRequest(target *Address) []byte {
|
||||||
if target == nil {
|
if target == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -148,18 +184,12 @@ type SocksUDPSession struct {
|
|||||||
|
|
||||||
// DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray.
|
// DialUDP establishes a SOCKS5 UDP ASSOCIATE tunnel to Xray.
|
||||||
func (r *SocksRelay) DialUDP(ctx context.Context, user string) (*SocksUDPSession, error) {
|
func (r *SocksRelay) DialUDP(ctx context.Context, user string) (*SocksUDPSession, error) {
|
||||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
ctrl, err := r.dialAuthenticated(ctx, user)
|
||||||
ctrl, err := dialer.DialContext(ctx, "tcp", r.Addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tuic socks: dial UDP control connection: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = ctrl.SetDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
|
|
||||||
if err := socks5Handshake(ctrl, user, r.Password); err != nil {
|
|
||||||
ctrl.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
stop := context.AfterFunc(ctx, func() { _ = ctrl.Close() })
|
||||||
|
defer stop()
|
||||||
|
|
||||||
// SOCKS5 UDP ASSOCIATE (0x03), dst 0.0.0.0:0
|
// SOCKS5 UDP ASSOCIATE (0x03), dst 0.0.0.0:0
|
||||||
if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||||
@@ -180,6 +210,11 @@ func (r *SocksRelay) DialUDP(ctx context.Context, user string) (*SocksUDPSession
|
|||||||
ctrl.Close()
|
ctrl.Close()
|
||||||
return nil, fmt.Errorf("tuic socks: dial UDP relay endpoint %s: %w", bind, err)
|
return nil, fmt.Errorf("tuic socks: dial UDP relay endpoint %s: %w", bind, err)
|
||||||
}
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
_ = ctrl.Close()
|
||||||
|
_ = udpConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &SocksUDPSession{
|
return &SocksUDPSession{
|
||||||
ctrl: ctrl,
|
ctrl: ctrl,
|
||||||
@@ -558,19 +593,19 @@ func SocksPassword() string {
|
|||||||
return socksPassword
|
return socksPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// SocksInboundSettings builds the JSON `settings` block for a stock Xray
|
// SocksInboundSettings preserves each client email as the Xray routing identity.
|
||||||
// SOCKS5 inbound with one username/password account per email, all sharing
|
// The caller selects a policy level so relay traffic is not billed twice.
|
||||||
// password. UDP is enabled for UDP ASSOCIATE proxying.
|
func SocksInboundSettings(emails []string, password string, userLevel uint32) ([]byte, error) {
|
||||||
func SocksInboundSettings(emails []string, password string) ([]byte, error) {
|
|
||||||
type account struct {
|
type account struct {
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Pass string `json:"pass"`
|
Pass string `json:"pass"`
|
||||||
}
|
}
|
||||||
settings := struct {
|
settings := struct {
|
||||||
Auth string `json:"auth"`
|
Auth string `json:"auth"`
|
||||||
UDP bool `json:"udp"`
|
UDP bool `json:"udp"`
|
||||||
Accounts []account `json:"accounts"`
|
UserLevel uint32 `json:"userLevel"`
|
||||||
}{Auth: "password", UDP: true}
|
Accounts []account `json:"accounts"`
|
||||||
|
}{Auth: "password", UDP: true, UserLevel: userLevel}
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
|
settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *clientquic.Conn, uuid.UUID, string) {
|
func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *clientquic.Conn, uuid.UUID, string) {
|
||||||
|
t.Helper()
|
||||||
|
return startLifecycleTestServerWithRelay(t, &SocksRelay{Addr: relayAddr, Password: "lifecycle-socks-password"}, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startLifecycleTestServerWithRelay(t *testing.T, relay *SocksRelay, email string) (*Server, *clientquic.Conn, uuid.UUID, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cert, key := generateTestCert(t)
|
cert, key := generateTestCert(t)
|
||||||
clientID := uuid.New()
|
clientID := uuid.New()
|
||||||
@@ -24,7 +29,7 @@ func startLifecycleTestServer(t *testing.T, relayAddr, email string) (*Server, *
|
|||||||
Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
|
Certificate: string(cert), PrivateKey: string(key), ALPN: []string{"h3"},
|
||||||
AuthenticationTimeout: 2, MaxIdleTime: 30,
|
AuthenticationTimeout: 2, MaxIdleTime: 30,
|
||||||
Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: email}},
|
Clients: []TuicClientSettings{{UUID: clientID.String(), Password: password, Email: email}},
|
||||||
}, &SocksRelay{Addr: relayAddr, Password: "lifecycle-socks-password"})
|
}, relay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create TUIC server: %v", err)
|
t.Fatalf("create TUIC server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -95,6 +95,16 @@ func (inst Instance) FullFingerprint() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||||
|
return instanceFromInbound(ib, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfiguredInstanceFromInbound includes disabled clients for stable relay identity.
|
||||||
|
// Only the native server decides whether a client may authenticate.
|
||||||
|
func ConfiguredInstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||||
|
return instanceFromInbound(ib, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func instanceFromInbound(ib *model.Inbound, includeDisabled bool) (Instance, bool) {
|
||||||
if ib == nil || ib.Protocol != model.TUIC {
|
if ib == nil || ib.Protocol != model.TUIC {
|
||||||
return Instance{}, false
|
return Instance{}, false
|
||||||
}
|
}
|
||||||
@@ -221,7 +231,7 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
|
|
||||||
clients := make([]TuicClientSettings, 0, len(parsed.Clients))
|
clients := make([]TuicClientSettings, 0, len(parsed.Clients))
|
||||||
for _, c := range parsed.Clients {
|
for _, c := range parsed.Clients {
|
||||||
if c.Enable != nil && !*c.Enable {
|
if !includeDisabled && c.Enable != nil && !*c.Enable {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uuidVal := strings.ToLower(strings.TrimSpace(c.UUID))
|
uuidVal := strings.ToLower(strings.TrimSpace(c.UUID))
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type udpAssociation struct {
|
|||||||
responseTransport uint8
|
responseTransport uint8
|
||||||
relay *udpRelaySession
|
relay *udpRelaySession
|
||||||
lastActive time.Time
|
lastActive time.Time
|
||||||
|
opening bool
|
||||||
|
openCancel context.CancelFunc
|
||||||
|
pending []pendingUdpPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
type udpAssociationRegistry struct {
|
type udpAssociationRegistry struct {
|
||||||
@@ -26,6 +29,8 @@ type udpAssociationRegistry struct {
|
|||||||
datagramReassembler *packetReassembler
|
datagramReassembler *packetReassembler
|
||||||
streamReassembler *packetReassembler
|
streamReassembler *packetReassembler
|
||||||
maxPacketSize int
|
maxPacketSize int
|
||||||
|
pendingBytes int
|
||||||
|
pendingBudget *pendingUdpBudget
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUdpAssociationRegistry(maxPacketSize int) *udpAssociationRegistry {
|
func newUdpAssociationRegistry(maxPacketSize int) *udpAssociationRegistry {
|
||||||
@@ -115,6 +120,9 @@ func (r *udpAssociationRegistry) ensureRelay(ctx context.Context, assocID uint16
|
|||||||
func (r *udpAssociationRegistry) dissociate(assocID uint16) bool {
|
func (r *udpAssociationRegistry) dissociate(assocID uint16) bool {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
association := r.associations[assocID]
|
association := r.associations[assocID]
|
||||||
|
if association != nil {
|
||||||
|
r.clearPendingLocked(association)
|
||||||
|
}
|
||||||
delete(r.associations, assocID)
|
delete(r.associations, assocID)
|
||||||
r.datagramReassembler.clearAssociation(assocID)
|
r.datagramReassembler.clearAssociation(assocID)
|
||||||
r.streamReassembler.clearAssociation(assocID)
|
r.streamReassembler.clearAssociation(assocID)
|
||||||
@@ -151,6 +159,7 @@ func (r *udpAssociationRegistry) expireLocked(now time.Time) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(r.associations, assocID)
|
delete(r.associations, assocID)
|
||||||
|
r.clearPendingLocked(association)
|
||||||
r.datagramReassembler.clearAssociation(assocID)
|
r.datagramReassembler.clearAssociation(assocID)
|
||||||
r.streamReassembler.clearAssociation(assocID)
|
r.streamReassembler.clearAssociation(assocID)
|
||||||
if association.relay != nil {
|
if association.relay != nil {
|
||||||
@@ -162,6 +171,7 @@ func (r *udpAssociationRegistry) expireLocked(now time.Time) {
|
|||||||
func (r *udpAssociationRegistry) closeAll() {
|
func (r *udpAssociationRegistry) closeAll() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
for _, association := range r.associations {
|
for _, association := range r.associations {
|
||||||
|
r.clearPendingLocked(association)
|
||||||
if association.relay != nil {
|
if association.relay != nil {
|
||||||
_ = association.relay.relay.Close()
|
_ = association.relay.relay.Close()
|
||||||
}
|
}
|
||||||
@@ -175,6 +185,7 @@ func (r *udpAssociationRegistry) closeAll() {
|
|||||||
func (r *udpAssociationRegistry) release(id uint16, expected *udpAssociation) {
|
func (r *udpAssociationRegistry) release(id uint16, expected *udpAssociation) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
if r.associations[id] == expected {
|
if r.associations[id] == expected {
|
||||||
|
r.clearPendingLocked(expected)
|
||||||
delete(r.associations, id)
|
delete(r.associations, id)
|
||||||
r.datagramReassembler.clearAssociation(id)
|
r.datagramReassembler.clearAssociation(id)
|
||||||
r.streamReassembler.clearAssociation(id)
|
r.streamReassembler.clearAssociation(id)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package tuic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxPendingUdpBytesPerInbound = 1 << 20
|
||||||
|
maxPendingUdpBytesPerConn = 128 << 10
|
||||||
|
maxPendingUdpPackets = 16
|
||||||
|
udpPacketOverhead = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
var errUdpQueueFull = errors.New("tuic: pending UDP packet limit reached")
|
||||||
|
|
||||||
|
type pendingUdpBudget struct{ bytes atomic.Int64 }
|
||||||
|
|
||||||
|
func (b *pendingUdpBudget) reserve(size int) bool {
|
||||||
|
if b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
used := b.bytes.Load()
|
||||||
|
if used+int64(size) > maxPendingUdpBytesPerInbound {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if b.bytes.CompareAndSwap(used, used+int64(size)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingUdpPacket struct {
|
||||||
|
target *Address
|
||||||
|
payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *udpAssociationRegistry) queuePacket(ctx context.Context, id uint16, expected *udpAssociation, target *Address, payload []byte) (*udpRelaySession, context.Context, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.associations[id] != expected {
|
||||||
|
return nil, nil, errUdpAssociationClosed
|
||||||
|
}
|
||||||
|
if expected.relay != nil && !expected.opening {
|
||||||
|
return expected.relay, nil, nil
|
||||||
|
}
|
||||||
|
charge := len(payload) + udpPacketOverhead
|
||||||
|
if len(expected.pending) >= maxPendingUdpPackets || r.pendingBytes+charge > maxPendingUdpBytesPerConn || !r.pendingBudget.reserve(charge) {
|
||||||
|
return nil, nil, errUdpQueueFull
|
||||||
|
}
|
||||||
|
r.pendingBytes += charge
|
||||||
|
addr := *target
|
||||||
|
addr.IP = append([]byte(nil), target.IP...)
|
||||||
|
expected.pending = append(expected.pending, pendingUdpPacket{target: &addr, payload: append([]byte(nil), payload...)})
|
||||||
|
if expected.opening {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
expected.opening = true
|
||||||
|
opening, cancel := context.WithCancel(ctx)
|
||||||
|
expected.openCancel = cancel
|
||||||
|
return nil, opening, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *udpAssociationRegistry) nextPending(id uint16, expected *udpAssociation) (pendingUdpPacket, bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.associations[id] != expected || len(expected.pending) == 0 {
|
||||||
|
expected.opening = false
|
||||||
|
if expected.openCancel != nil {
|
||||||
|
expected.openCancel()
|
||||||
|
expected.openCancel = nil
|
||||||
|
}
|
||||||
|
return pendingUdpPacket{}, false
|
||||||
|
}
|
||||||
|
packet := expected.pending[0]
|
||||||
|
expected.pending[0] = pendingUdpPacket{}
|
||||||
|
expected.pending = expected.pending[1:]
|
||||||
|
return packet, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *udpAssociationRegistry) releasePendingPacketLocked(packet pendingUdpPacket) {
|
||||||
|
charge := len(packet.payload) + udpPacketOverhead
|
||||||
|
r.pendingBytes -= charge
|
||||||
|
if r.pendingBudget != nil {
|
||||||
|
r.pendingBudget.bytes.Add(-int64(charge))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *udpAssociationRegistry) releasePendingPacket(packet pendingUdpPacket) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.releasePendingPacketLocked(packet)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *udpAssociationRegistry) clearPendingLocked(association *udpAssociation) {
|
||||||
|
for _, packet := range association.pending {
|
||||||
|
r.releasePendingPacketLocked(packet)
|
||||||
|
}
|
||||||
|
association.pending = nil
|
||||||
|
association.opening = false
|
||||||
|
if association.openCancel != nil {
|
||||||
|
association.openCancel()
|
||||||
|
association.openCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *udpAssociationRegistry) failPending(expected *udpAssociation) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.clearPendingLocked(expected)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package tuic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pendingTestAssociation(t *testing.T, registry *udpAssociationRegistry, id uint16, size int) (*udpAssociation, *Address, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
target := &Address{Type: AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 53}
|
||||||
|
payload := make([]byte, size)
|
||||||
|
association, _, _, complete := registry.feed(packetTransportDatagram, &PacketHeader{
|
||||||
|
AssocID: id, PktID: 1, FragTotal: 1, Size: uint16(size), Addr: target,
|
||||||
|
}, payload)
|
||||||
|
if association == nil || !complete {
|
||||||
|
t.Fatal("valid UDP packet did not create association")
|
||||||
|
}
|
||||||
|
return association, target, payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingUdpPacketLimitsReleaseCapacityAfterDissociate(t *testing.T) {
|
||||||
|
registry := newUdpAssociationRegistry(1500)
|
||||||
|
registry.pendingBudget = &pendingUdpBudget{}
|
||||||
|
association, target, payload := pendingTestAssociation(t, registry, 1, 1000)
|
||||||
|
for range 16 {
|
||||||
|
if _, _, err := registry.queuePacket(context.Background(), 1, association, target, payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := registry.queuePacket(context.Background(), 1, association, target, payload); !errors.Is(err, errUdpQueueFull) {
|
||||||
|
t.Fatalf("association overflow error = %v, want %v", err, errUdpQueueFull)
|
||||||
|
}
|
||||||
|
registry.dissociate(1)
|
||||||
|
if used := registry.pendingBudget.bytes.Load(); used != 0 {
|
||||||
|
t.Fatalf("dissociation retained %d queued bytes", used)
|
||||||
|
}
|
||||||
|
replacement, _, _ := pendingTestAssociation(t, registry, 1, 1000)
|
||||||
|
if _, _, err := registry.queuePacket(context.Background(), 1, association, target, payload); !errors.Is(err, errUdpAssociationClosed) {
|
||||||
|
t.Fatalf("stale worker queued into reused association: %v", err)
|
||||||
|
}
|
||||||
|
if _, opening, err := registry.queuePacket(context.Background(), 1, replacement, target, payload); err != nil || opening == nil {
|
||||||
|
t.Fatalf("released capacity unavailable to replacement: opening=%v err=%v", opening, err)
|
||||||
|
}
|
||||||
|
registry.closeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingUdpByteBudgetsBoundMultipleConnections(t *testing.T) {
|
||||||
|
budget := &pendingUdpBudget{}
|
||||||
|
var registries []*udpAssociationRegistry
|
||||||
|
defer func() {
|
||||||
|
for _, registry := range registries {
|
||||||
|
registry.closeAll()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
blockedInbound := false
|
||||||
|
for connID := 0; connID < 12; connID++ {
|
||||||
|
registry := newUdpAssociationRegistry(1500)
|
||||||
|
registry.pendingBudget = budget
|
||||||
|
registries = append(registries, registry)
|
||||||
|
blockedConn := false
|
||||||
|
for id := uint16(1); id < 256; id++ {
|
||||||
|
association, target, payload := pendingTestAssociation(t, registry, id, 1500)
|
||||||
|
_, _, err := registry.queuePacket(context.Background(), id, association, target, payload)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errUdpQueueFull) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if connID == 0 && registry.pendingBytes+1756 <= 128<<10 {
|
||||||
|
t.Fatal("first connection hit a limit before filling its 128 KiB allowance")
|
||||||
|
}
|
||||||
|
blockedConn = registry.pendingBytes+1756 > 128<<10
|
||||||
|
blockedInbound = budget.bytes.Load()+1756 > 1<<20
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if registry.pendingBytes > 128<<10 || budget.bytes.Load() > 1<<20 {
|
||||||
|
t.Fatal("pending UDP payloads exceeded their memory budgets")
|
||||||
|
}
|
||||||
|
if blockedInbound {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !blockedConn {
|
||||||
|
t.Fatal("connection queue admitted all associations without enforcing byte budget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !blockedInbound {
|
||||||
|
t.Fatal("separate QUIC connections bypassed the shared inbound byte budget")
|
||||||
|
}
|
||||||
|
for _, registry := range registries {
|
||||||
|
registry.closeAll()
|
||||||
|
}
|
||||||
|
if used := budget.bytes.Load(); used != 0 {
|
||||||
|
t.Fatalf("closing connections retained %d queued bytes", used)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingUdpRemovalCancelsDialAndReleasesQueuedAndInFlightPackets(t *testing.T) {
|
||||||
|
for _, remove := range []string{"dissociate", "expire", "shutdown", "reader-failure", "dial-failure"} {
|
||||||
|
t.Run(remove, func(t *testing.T) {
|
||||||
|
registry := newUdpAssociationRegistry(1500)
|
||||||
|
registry.pendingBudget = &pendingUdpBudget{}
|
||||||
|
association, target, payload := pendingTestAssociation(t, registry, 1, 100)
|
||||||
|
_, opening, err := registry.queuePacket(context.Background(), 1, association, target, payload)
|
||||||
|
if err != nil || opening == nil {
|
||||||
|
t.Fatalf("queue first UDP packet: opening=%v err=%v", opening, err)
|
||||||
|
}
|
||||||
|
if _, _, err := registry.queuePacket(context.Background(), 1, association, target, payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inFlight, ok := registry.nextPending(1, association)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("first pending packet disappeared before removal")
|
||||||
|
}
|
||||||
|
switch remove {
|
||||||
|
case "dissociate":
|
||||||
|
registry.dissociate(1)
|
||||||
|
case "expire":
|
||||||
|
registry.reapIdle(time.Now().Add(udpAssociationIdleTimeout + time.Second))
|
||||||
|
case "shutdown":
|
||||||
|
registry.closeAll()
|
||||||
|
case "reader-failure":
|
||||||
|
registry.release(1, association)
|
||||||
|
case "dial-failure":
|
||||||
|
registry.failPending(association)
|
||||||
|
}
|
||||||
|
if !errors.Is(opening.Err(), context.Canceled) {
|
||||||
|
t.Fatal("association removal left its SOCKS dial alive")
|
||||||
|
}
|
||||||
|
registry.releasePendingPacket(inFlight)
|
||||||
|
if used := registry.pendingBudget.bytes.Load(); used != 0 || registry.pendingBytes != 0 {
|
||||||
|
t.Fatalf("removal leaked or double released packet budget: inbound=%d connection=%d", used, registry.pendingBytes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LocalDeps struct {
|
type LocalDeps struct {
|
||||||
APIPort func() int
|
APIPort func() int
|
||||||
SetNeedRestart func()
|
SetNeedRestart func()
|
||||||
|
RequestReconcile func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Local struct {
|
type Local struct {
|
||||||
@@ -91,8 +92,8 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
err := tuic.GetManager().Ensure(inst)
|
err := tuic.GetManager().Ensure(inst)
|
||||||
if l.deps.SetNeedRestart != nil {
|
if l.deps.RequestReconcile != nil {
|
||||||
l.deps.SetNeedRestart()
|
l.deps.RequestReconcile()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,8 +123,8 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
|
|||||||
}
|
}
|
||||||
if ib.Protocol == model.TUIC {
|
if ib.Protocol == model.TUIC {
|
||||||
tuic.GetManager().Remove(ib.Id)
|
tuic.GetManager().Remove(ib.Id)
|
||||||
if l.deps.SetNeedRestart != nil {
|
if l.deps.RequestReconcile != nil {
|
||||||
l.deps.SetNeedRestart()
|
l.deps.RequestReconcile()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -233,11 +234,12 @@ func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
|
||||||
|
// Account changes also need the generated Xray SOCKS relay refreshed after commit.
|
||||||
|
if l.deps.RequestReconcile != nil {
|
||||||
|
l.deps.RequestReconcile()
|
||||||
|
}
|
||||||
if oldIb.Protocol == model.TUIC && newIb.Protocol != model.TUIC {
|
if oldIb.Protocol == model.TUIC && newIb.Protocol != model.TUIC {
|
||||||
tuic.GetManager().Remove(oldIb.Id)
|
tuic.GetManager().Remove(oldIb.Id)
|
||||||
if l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
if !newIb.Enable {
|
if !newIb.Enable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -245,25 +247,11 @@ func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbou
|
|||||||
}
|
}
|
||||||
if oldIb.Protocol != model.TUIC {
|
if oldIb.Protocol != model.TUIC {
|
||||||
_ = l.DelInbound(ctx, oldIb)
|
_ = 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 {
|
if !newIb.Enable {
|
||||||
tuic.GetManager().Remove(newIb.Id)
|
tuic.GetManager().Remove(newIb.Id)
|
||||||
if oldIb.Enable && l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !oldIb.Enable && newIb.Enable {
|
|
||||||
if l.deps.SetNeedRestart != nil {
|
|
||||||
l.deps.SetNeedRestart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inst, ok := tuic.InstanceFromInbound(newIb)
|
inst, ok := tuic.InstanceFromInbound(newIb)
|
||||||
if !ok {
|
if !ok {
|
||||||
tuic.GetManager().Remove(newIb.Id)
|
tuic.GetManager().Remove(newIb.Id)
|
||||||
@@ -273,7 +261,13 @@ func (l *Local) updateTuicInbound(ctx context.Context, oldIb, newIb *model.Inbou
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
|
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
|
||||||
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
|
if ib.Protocol == model.TUIC {
|
||||||
|
if l.deps.RequestReconcile != nil {
|
||||||
|
l.deps.RequestReconcile()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
return l.withAPI(func(api *xray.XrayAPI) error {
|
||||||
@@ -282,7 +276,13 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
|
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
|
||||||
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG || ib.Protocol == model.TUIC {
|
if ib.Protocol == model.TUIC {
|
||||||
|
if l.deps.RequestReconcile != nil {
|
||||||
|
l.deps.RequestReconcile()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return l.withAPI(func(api *xray.XrayAPI) error {
|
return l.withAPI(func(api *xray.XrayAPI) error {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
|
func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
|
||||||
resyncs := 0
|
resyncs := 0
|
||||||
local := NewLocal(LocalDeps{SetNeedRestart: func() { resyncs++ }})
|
local := NewLocal(LocalDeps{RequestReconcile: func() { resyncs++ }})
|
||||||
oldInbound := &model.Inbound{Id: 940001, Tag: "old-tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[]}`}
|
oldInbound := &model.Inbound{Id: 940001, Tag: "old-tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[]}`}
|
||||||
newInbound := *oldInbound
|
newInbound := *oldInbound
|
||||||
newInbound.Tag = "new-tuic-tag"
|
newInbound.Tag = "new-tuic-tag"
|
||||||
@@ -22,17 +22,37 @@ func TestUpdateTuicInboundResyncsBridgeOnTagRename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateTuicInboundDoesNotResyncForProfileOnlyChange(t *testing.T) {
|
// applyLocalTuic reads the row after commit, so old/new payloads may already match.
|
||||||
|
func TestUpdateTuicInboundResyncsAccountsWithIdenticalPayloads(t *testing.T) {
|
||||||
resyncs := 0
|
resyncs := 0
|
||||||
local := NewLocal(LocalDeps{SetNeedRestart: func() { resyncs++ }})
|
local := NewLocal(LocalDeps{RequestReconcile: func() { resyncs++ }})
|
||||||
oldInbound := &model.Inbound{Id: 940002, Tag: "tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[],"congestion_control":"bbr"}`}
|
inbound := &model.Inbound{Id: 940002, Tag: "tuic-tag", Protocol: model.TUIC, Enable: true, Settings: `{"clients":[]}`}
|
||||||
newInbound := *oldInbound
|
if err := local.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||||
newInbound.Settings = `{"clients":[],"congestion_control":"cubic","log_level":"debug"}`
|
t.Fatal(err)
|
||||||
|
|
||||||
if err := local.updateTuicInbound(context.Background(), oldInbound, &newInbound); err != nil {
|
|
||||||
t.Fatalf("updateTuicInbound: %v", err)
|
|
||||||
}
|
}
|
||||||
if resyncs != 0 {
|
if resyncs != 1 {
|
||||||
t.Fatalf("profile-only update triggered %d Xray restarts, want 0", resyncs)
|
t.Fatalf("relay account resync requests = %d, want 1", resyncs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicBulkUserOperationsResyncRelayAccounts(t *testing.T) {
|
||||||
|
for _, operation := range []string{"add", "remove"} {
|
||||||
|
t.Run(operation, func(t *testing.T) {
|
||||||
|
resyncs := 0
|
||||||
|
local := NewLocal(LocalDeps{RequestReconcile: func() { resyncs++ }})
|
||||||
|
inbound := &model.Inbound{Id: 940003, Protocol: model.TUIC, Enable: true}
|
||||||
|
var err error
|
||||||
|
if operation == "add" {
|
||||||
|
err = local.AddUser(context.Background(), inbound, map[string]any{"email": "bulk@tuic"})
|
||||||
|
} else {
|
||||||
|
err = local.RemoveUser(context.Background(), inbound, "bulk@tuic")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resyncs != 1 {
|
||||||
|
t.Fatalf("%s requested %d relay reconciliations, want 1", operation, resyncs)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
@@ -454,6 +455,9 @@ func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound)
|
|||||||
c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
|
c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
|
||||||
}
|
}
|
||||||
case model.TUIC:
|
case model.TUIC:
|
||||||
|
if err := tuic.ValidateRelayEmail(c.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if c.ID == "" {
|
if c.ID == "" {
|
||||||
c.ID = uuid.NewString()
|
c.ID = uuid.NewString()
|
||||||
}
|
}
|
||||||
@@ -661,6 +665,17 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
|||||||
if err := validateClientEmail(updated.Email); err != nil {
|
if err := validateClientEmail(updated.Email); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if emailErr := tuic.ValidateRelayEmail(updated.Email); emailErr != nil {
|
||||||
|
var attached int64
|
||||||
|
if err := database.GetDB().Model(&model.Inbound{}).
|
||||||
|
Joins("JOIN client_inbounds ci ON ci.inbound_id = inbounds.id").
|
||||||
|
Where("ci.client_id = ? AND inbounds.protocol = ?", id, model.TUIC).Count(&attached).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if attached > 0 {
|
||||||
|
return false, emailErr
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := validateClientSubID(updated.SubID); err != nil {
|
if err := validateClientSubID(updated.SubID); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,6 +368,9 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if err := validateTuicClientEmails(oldInbound.Protocol, clients); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
existingClients, err := inboundSvc.GetClients(oldInbound)
|
existingClients, err := inboundSvc.GetClients(oldInbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -680,6 +683,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if err := validateTuicClientEmails(oldInbound.Protocol, clients); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
oldClients, err := inboundSvc.GetClients(oldInbound)
|
oldClients, err := inboundSvc.GetClients(oldInbound)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1436,6 +1436,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateTuicClientEmails(inbound.Protocol, clients); err != nil {
|
||||||
|
return inbound, false, err
|
||||||
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
var postCommitApply func()
|
var postCommitApply func()
|
||||||
err = runSerializedTx(func(tx *gorm.DB) error {
|
err = runSerializedTx(func(tx *gorm.DB) error {
|
||||||
@@ -1942,7 +1946,7 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return validateTuicClientEmails(inbound.Protocol, clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ func TestAddInboundTuicClientValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
|
func TestTuicClientMutationsRequestRelayReconciliation(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
|
resyncs := 0
|
||||||
|
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, RequestReconcile: func() { resyncs++ }})
|
||||||
runtime.SetManager(mgr)
|
runtime.SetManager(mgr)
|
||||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
|
|||||||
t.Fatalf("AddInbound failed: %v", err)
|
t.Fatalf("AddInbound failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resyncs = 0
|
||||||
addPayload := &model.Inbound{
|
addPayload := &model.Inbound{
|
||||||
Id: created.Id,
|
Id: created.Id,
|
||||||
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"p2","email":"user2@tuic.com","enable":true}]}`,
|
Settings: `{"clients":[{"id":"a0000000-0000-0000-0000-000000000002","password":"p2","email":"user2@tuic.com","enable":true}]}`,
|
||||||
@@ -102,6 +104,10 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
|
|||||||
if needRestart {
|
if needRestart {
|
||||||
t.Fatal("expected needRestart = false when adding TUIC client")
|
t.Fatal("expected needRestart = false when adding TUIC client")
|
||||||
}
|
}
|
||||||
|
if resyncs == 0 {
|
||||||
|
t.Fatal("adding a TUIC client did not request an Xray relay account refresh")
|
||||||
|
}
|
||||||
|
resyncs = 0
|
||||||
|
|
||||||
updatePayload := &model.Inbound{
|
updatePayload := &model.Inbound{
|
||||||
Id: created.Id,
|
Id: created.Id,
|
||||||
@@ -114,6 +120,10 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
|
|||||||
if needRestart {
|
if needRestart {
|
||||||
t.Fatal("expected needRestart = false when updating TUIC client")
|
t.Fatal("expected needRestart = false when updating TUIC client")
|
||||||
}
|
}
|
||||||
|
if resyncs == 0 {
|
||||||
|
t.Fatal("updating a TUIC client did not request an Xray relay account refresh")
|
||||||
|
}
|
||||||
|
resyncs = 0
|
||||||
|
|
||||||
needRestart, err = clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "user2@tuic.com", false, true)
|
needRestart, err = clientSvc.DelInboundClientByEmail(inboundSvc, created.Id, "user2@tuic.com", false, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,4 +132,7 @@ func TestTuicClientMutationsDoNotRequireRestart(t *testing.T) {
|
|||||||
if needRestart {
|
if needRestart {
|
||||||
t.Fatal("expected needRestart = false when deleting TUIC client")
|
t.Fatal("expected needRestart = false when deleting TUIC client")
|
||||||
}
|
}
|
||||||
|
if resyncs == 0 {
|
||||||
|
t.Fatal("deleting a TUIC client did not request an Xray relay account refresh")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,70 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(inbounds) == 0 {
|
return s.desiredTuicInstances(inbounds)
|
||||||
return nil, nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
func configuredTuicInstances(inbounds []*model.Inbound) []tuic.Instance {
|
||||||
|
instances := make([]tuic.Instance, 0)
|
||||||
|
for _, inbound := range inbounds {
|
||||||
|
if inbound.Protocol != model.TUIC || !inbound.Enable || inbound.NodeID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inst, ok := tuic.ConfiguredInstanceFromInbound(inbound)
|
||||||
|
if !ok || tuic.ValidateClients(inst.Clients) != nil {
|
||||||
|
logger.Warningf("tuic: skipping inbound %d: invalid settings or clients", inbound.Id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inst.Clients = supportedTuicRelayClients(inst)
|
||||||
|
instances = append(instances, inst)
|
||||||
|
}
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportedTuicRelayClients(inst tuic.Instance) []tuic.TuicClientSettings {
|
||||||
|
clients := make([]tuic.TuicClientSettings, 0, len(inst.Clients))
|
||||||
|
for _, client := range inst.Clients {
|
||||||
|
if tuic.ValidateRelayEmail(client.Email) == nil {
|
||||||
|
clients = append(clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clients) != len(inst.Clients) {
|
||||||
|
logger.Warningf("tuic: inbound %d: %d clients excluded: email must contain 1 to 255 UTF-8 bytes", inst.Id, len(inst.Clients)-len(clients))
|
||||||
|
}
|
||||||
|
return clients
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTuicClientEmails(protocol model.Protocol, clients []model.Client) error {
|
||||||
|
if protocol != model.TUIC {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, client := range clients {
|
||||||
|
if err := tuic.ValidateRelayEmail(client.Email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) desiredTuicInstances(inbounds []*model.Inbound) ([]tuic.Instance, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
reserved, err := templateInboundTags()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
instances := make([]tuic.Instance, 0, len(inbounds))
|
instances := make([]tuic.Instance, 0, len(inbounds))
|
||||||
for _, ib := range inbounds {
|
for _, ib := range inbounds {
|
||||||
|
if ib.Protocol != model.TUIC || !ib.Enable || ib.NodeID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := tuic.InstanceFromInbound(ib); !ok {
|
||||||
|
logger.Warningf("tuic: skipping inbound %d: invalid settings JSON", ib.Id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, taken := reserved[ib.Tag]; taken {
|
||||||
|
logger.Warningf("tuic: skipping inbound %d: tag conflicts with Xray template", ib.Id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
built, err := s.buildInboundForLocalRuntime(db, ib)
|
built, err := s.buildInboundForLocalRuntime(db, ib)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -32,6 +90,7 @@ func (s *InboundService) DesiredTuicInstances() ([]tuic.Instance, error) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
inst.Clients = supportedTuicRelayClients(inst)
|
||||||
instances = append(instances, inst)
|
instances = append(instances, inst)
|
||||||
}
|
}
|
||||||
emails := make([]string, 0)
|
emails := make([]string, 0)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInjectTuicSocks(t *testing.T) {
|
func TestInjectTuicRelay(t *testing.T) {
|
||||||
cfg := &xray.Config{}
|
cfg := &xray.Config{}
|
||||||
inbounds := []*model.Inbound{
|
inbounds := []*model.Inbound{
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,13 @@ func TestInjectTuicSocks(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
injectTuicSocks(cfg, inbounds)
|
inst, ok := tuic.InstanceFromInbound(inbounds[0])
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("invalid TUIC fixture")
|
||||||
|
}
|
||||||
|
if err := injectTuicRelay(cfg, []tuic.Instance{inst}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(cfg.InboundConfigs) != 1 {
|
if len(cfg.InboundConfigs) != 1 {
|
||||||
t.Fatalf("expected 1 injected SOCKS inbound, got %d", len(cfg.InboundConfigs))
|
t.Fatalf("expected 1 injected SOCKS inbound, got %d", len(cfg.InboundConfigs))
|
||||||
@@ -67,14 +73,21 @@ func TestInjectTuicSocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parsedSettings struct {
|
var parsedSettings struct {
|
||||||
Auth string `json:"auth"`
|
Auth string `json:"auth"`
|
||||||
UDP bool `json:"udp"`
|
UDP bool `json:"udp"`
|
||||||
|
Accounts []struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
} `json:"accounts"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
|
if err := json.Unmarshal(sc.Settings, &parsedSettings); err != nil {
|
||||||
t.Fatalf("failed to unmarshal settings: %v", err)
|
t.Fatalf("failed to unmarshal settings: %v", err)
|
||||||
}
|
}
|
||||||
if parsedSettings.Auth != "noauth" || !parsedSettings.UDP {
|
if parsedSettings.Auth != "password" || !parsedSettings.UDP {
|
||||||
t.Fatalf("expected auth=noauth, udp=true, got %+v", parsedSettings)
|
t.Fatalf("expected auth=password, udp=true, got %+v", parsedSettings)
|
||||||
|
}
|
||||||
|
if len(parsedSettings.Accounts) != 1 || parsedSettings.Accounts[0].User != "user1@example.com" || parsedSettings.Accounts[0].Pass != tuic.SocksPassword() {
|
||||||
|
t.Fatal("generated SOCKS account must authenticate the TUIC client using the relay password")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,3 +360,106 @@ func TestNormalizeTuicSettingsCanonicalizesCongestionAndPacketLimit(t *testing.T
|
|||||||
t.Fatal("unsupported congestion controller was accepted")
|
t.Fatal("unsupported congestion controller was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTuicRelayIdentitySurvivesNativeEligibilityChanges(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
clients := []model.Client{
|
||||||
|
{Email: "active@test", ID: "a0000000-0000-0000-0000-000000000071", Password: "p1", Enable: true},
|
||||||
|
{Email: "manual@test", ID: "a0000000-0000-0000-0000-000000000072", Password: "p2", Enable: false},
|
||||||
|
{Email: "quota@test", ID: "a0000000-0000-0000-0000-000000000073", Password: "p3", Enable: true},
|
||||||
|
{Email: "invalid@test", ID: "a0000000-0000-0000-0000-000000000074", Enable: true},
|
||||||
|
}
|
||||||
|
ib := mkInbound(t, 45371, model.TUIC, clientsSettings(t, clients))
|
||||||
|
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range clients {
|
||||||
|
if err := (&InboundService{}).AddClientStat(database.GetDB(), ib.Id, &c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "quota@test").Update("enable", false).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := tuicRelayAccountEmails(t, cfg, ib.Tag); !slices.Equal(got, []string{"active@test", "manual@test", "quota@test"}) {
|
||||||
|
t.Fatalf("relay accounts = %v, want configured identities independent of native eligibility", got)
|
||||||
|
}
|
||||||
|
instances, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil || len(instances) != 1 || len(instances[0].Clients) != 1 || instances[0].Clients[0].Email != "active@test" {
|
||||||
|
t.Fatalf("native TUIC eligibility differs from relay accounts: %+v, %v", instances, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings["sni"] = "profile.test"
|
||||||
|
settings["udp_relay_mode"] = "quic"
|
||||||
|
settings["congestion_control"] = "cubic"
|
||||||
|
settings["log_level"] = "debug"
|
||||||
|
if err := database.GetDB().Model(ib).Update("settings", string(mustJSON(t, settings))).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profileCfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if diff, hot := xray.ComputeHotDiff(cfg, profileCfg); !hot || !diff.Empty() {
|
||||||
|
t.Fatalf("TUIC profile/runtime settings changed Xray config: hot=%t diff=%+v", hot, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "quota@test").Update("enable", true).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
renewedCfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := tuicRelayAccountEmails(t, renewedCfg, ib.Tag); !slices.Equal(got, []string{"active@test", "manual@test", "quota@test"}) {
|
||||||
|
t.Fatalf("renewed relay accounts = %v, want all configured identities", got)
|
||||||
|
}
|
||||||
|
if diff, hot := xray.ComputeHotDiff(profileCfg, renewedCfg); !hot || !diff.Empty() {
|
||||||
|
t.Fatal("quota renewal must leave relay admission unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.GetDB().Model(ib).Update("settings", `{"clients":[]}`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
emptyCfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, inbound := range emptyCfg.InboundConfigs {
|
||||||
|
if inbound.Tag == ib.Tag && len(tuicRelayAccountEmails(t, emptyCfg, ib.Tag)) != 0 {
|
||||||
|
t.Fatal("removed clients must lose relay credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tuicRelayAccountEmails(t *testing.T, cfg *xray.Config, tag string) []string {
|
||||||
|
t.Helper()
|
||||||
|
for _, ib := range cfg.InboundConfigs {
|
||||||
|
if ib.Tag != tag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var settings struct {
|
||||||
|
Accounts []struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
} `json:"accounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ib.Settings, &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
emails := make([]string, 0, len(settings.Accounts))
|
||||||
|
for _, account := range settings.Accounts {
|
||||||
|
emails = append(emails, account.User)
|
||||||
|
}
|
||||||
|
return emails
|
||||||
|
}
|
||||||
|
t.Fatalf("missing generated TUIC relay %q", tag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -723,6 +723,13 @@ func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
||||||
|
reserved, err := templateInboundTags()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, exists := reserved[tag]; exists {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
|
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
|
||||||
if ignoreId > 0 {
|
if ignoreId > 0 {
|
||||||
@@ -734,3 +741,31 @@ func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
|||||||
}
|
}
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func templateInboundTags() (map[string]struct{}, error) {
|
||||||
|
raw, err := (&SettingService{}).GetXrayConfigTemplate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg struct {
|
||||||
|
Inbounds []struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
} `json:"inbounds"`
|
||||||
|
API struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
} `json:"api"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags := map[string]struct{}{"api": {}}
|
||||||
|
if cfg.API.Tag != "" {
|
||||||
|
tags[cfg.API.Tag] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, inbound := range cfg.Inbounds {
|
||||||
|
if inbound.Tag != "" {
|
||||||
|
tags[inbound.Tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
type relayAccount struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Pass string `json:"pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tuicRelayUpdate struct {
|
||||||
|
update *tuic.RelayUpdate
|
||||||
|
previous, next *xray.InboundConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishTuicRelayUpdate(update *tuic.RelayUpdate, cfg *xray.InboundConfig) {
|
||||||
|
var owner int
|
||||||
|
var emails []string
|
||||||
|
if cfg != nil {
|
||||||
|
owner = cfg.TuicInboundID
|
||||||
|
var settings struct {
|
||||||
|
Accounts []relayAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(cfg.Settings, &settings) != nil {
|
||||||
|
update.Finish(owner, nil, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, account := range settings.Accounts {
|
||||||
|
emails = append(emails, account.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update.Finish(owner, emails, cfg != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateTuicRelayReadiness(cfg *xray.Config) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, inbound := range cfg.InboundConfigs {
|
||||||
|
if inbound.TuicRelay {
|
||||||
|
if gate := tuic.ManagedRelayGate(inbound.Port); gate != nil {
|
||||||
|
gate.Invalidate(inbound.TuicInboundID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishTuicRelayReadiness(cfg *xray.Config) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return publishTuicRelayBatch(ctx, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishTuicRelayBatch(ctx context.Context, cfg *xray.Config) error {
|
||||||
|
invalidateTuicRelayReadiness(cfg)
|
||||||
|
failures := make([]error, len(cfg.InboundConfigs))
|
||||||
|
slots := make(chan struct{}, 4)
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
for i := range cfg.InboundConfigs {
|
||||||
|
inbound := &cfg.InboundConfigs[i]
|
||||||
|
if !inbound.TuicRelay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gate := tuic.ManagedRelayGate(inbound.Port)
|
||||||
|
if gate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateTuicRelay(inbound); err != nil {
|
||||||
|
failures[i] = fmt.Errorf("TUIC relay %s not ready: %w", inbound.Tag, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
failures[i] = ctx.Err()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
workers.Add(1)
|
||||||
|
go func(index int, inbound *xray.InboundConfig, gate *tuic.RelayGate) {
|
||||||
|
defer workers.Done()
|
||||||
|
defer func() { <-slots }()
|
||||||
|
failures[index] = publishTuicRelay(ctx, inbound, gate)
|
||||||
|
}(i, inbound, gate)
|
||||||
|
}
|
||||||
|
workers.Wait()
|
||||||
|
err := errors.Join(failures...)
|
||||||
|
if err != nil {
|
||||||
|
(&XrayService{}).SetToNeedReconcile()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishTuicRelay(ctx context.Context, inbound *xray.InboundConfig, gate *tuic.RelayGate) error {
|
||||||
|
update, err := gate.BeginUpdate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("publish TUIC relay %s readiness: %w", inbound.Tag, err)
|
||||||
|
}
|
||||||
|
// Starting the child process returns before its listeners have bound.
|
||||||
|
// Keep native sessions waiting until the configured accounts really work.
|
||||||
|
for {
|
||||||
|
err = verifyTuicRelayContext(ctx, nil, inbound)
|
||||||
|
if err == nil || ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
update.Finish(inbound.TuicInboundID, nil, false)
|
||||||
|
return fmt.Errorf("TUIC relay %s not ready: %w", inbound.Tag, err)
|
||||||
|
}
|
||||||
|
finishTuicRelayUpdate(update, inbound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailableTuicRepairs(cfg *xray.Config) []xray.InboundReplacement {
|
||||||
|
var repairs []xray.InboundReplacement
|
||||||
|
for i := range cfg.InboundConfigs {
|
||||||
|
inbound := &cfg.InboundConfigs[i]
|
||||||
|
if !inbound.TuicRelay {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gate := tuic.ManagedRelayGate(inbound.Port)
|
||||||
|
if gate != nil && !gate.ReadyFor(inbound.TuicInboundID) {
|
||||||
|
repairs = append(repairs, xray.InboundReplacement{Previous: inbound, Next: inbound})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repairs
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairUnavailableTuicRelays(cfg *xray.Config, apiPort int) error {
|
||||||
|
repairs := unavailableTuicRepairs(cfg)
|
||||||
|
if len(repairs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
api := &xray.XrayAPI{}
|
||||||
|
if err := api.Init(apiPort); err != nil {
|
||||||
|
(&XrayService{}).SetToNeedReconcile()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
err := applyTuicRelayReplacements(api, repairs)
|
||||||
|
if err != nil {
|
||||||
|
(&XrayService{}).SetToNeedReconcile()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTuicRelayReplacements(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
|
||||||
|
var failures error
|
||||||
|
for _, group := range groupTuicRelayChanges(changes) {
|
||||||
|
failures = errors.Join(failures, applyTuicRelayGroup(api, group))
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTuicRelay(inbound *xray.InboundConfig) error {
|
||||||
|
var settings struct {
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
Accounts []relayAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(inbound.Settings, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inbound.Protocol != "socks" || settings.Auth != "password" || string(inbound.Listen) != `"127.0.0.1"` {
|
||||||
|
return errors.New("TUIC relay must require authentication on loopback")
|
||||||
|
}
|
||||||
|
for _, account := range settings.Accounts {
|
||||||
|
if len(account.User) == 0 || len(account.User) > 255 || len(account.Pass) == 0 || len(account.Pass) > 255 {
|
||||||
|
return errors.New("TUIC relay credentials exceed SOCKS5 bounds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tuicRelayTransaction struct{ states map[int]*tuicRelayUpdate }
|
||||||
|
|
||||||
|
func (t *tuicRelayTransaction) finish() {
|
||||||
|
for _, state := range t.states {
|
||||||
|
if state.update != nil {
|
||||||
|
finishTuicRelayUpdate(state.update, state.next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tuicRelayTransaction) rollback(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
|
||||||
|
reverse := make([]xray.InboundReplacement, 0, len(changes))
|
||||||
|
for i := len(changes) - 1; i >= 0; i-- {
|
||||||
|
reverse = append(reverse, xray.InboundReplacement{Previous: changes[i].Next, Next: changes[i].Previous})
|
||||||
|
}
|
||||||
|
err := replaceTuicRelaySet(api, reverse)
|
||||||
|
for _, state := range t.states {
|
||||||
|
state.next = state.previous
|
||||||
|
if err != nil {
|
||||||
|
state.next = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTuicRelayGroup(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
|
||||||
|
transaction, err := stageTuicRelayGroup(api, changes)
|
||||||
|
if transaction != nil {
|
||||||
|
transaction.finish()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageTuicRelayGroup(api *xray.XrayAPI, changes []xray.InboundReplacement) (*tuicRelayTransaction, error) {
|
||||||
|
for _, change := range changes {
|
||||||
|
if change.Next != nil {
|
||||||
|
if err := validateTuicRelay(change.Next); err != nil {
|
||||||
|
return nil, fmt.Errorf("TUIC relay %s update: %w", change.Next.Tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
states := make(map[int]*tuicRelayUpdate)
|
||||||
|
for _, change := range changes {
|
||||||
|
for _, cfg := range []*xray.InboundConfig{change.Previous, change.Next} {
|
||||||
|
if cfg != nil && states[cfg.Port] == nil {
|
||||||
|
states[cfg.Port] = &tuicRelayUpdate{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if old := change.Previous; old != nil {
|
||||||
|
if states[old.Port].previous == nil {
|
||||||
|
states[old.Port].previous = old
|
||||||
|
}
|
||||||
|
states[old.Port].next = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, change := range changes {
|
||||||
|
if next := change.Next; next != nil {
|
||||||
|
states[next.Port].next = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ports := make([]int, 0, len(states))
|
||||||
|
for port := range states {
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
sort.Ints(ports)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, port := range ports {
|
||||||
|
gate := tuic.ManagedRelayGate(port)
|
||||||
|
if gate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
update, err := gate.BeginUpdate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
for _, state := range states {
|
||||||
|
if state.update != nil {
|
||||||
|
state.update.Cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("pause TUIC relay: %w", err)
|
||||||
|
}
|
||||||
|
states[port].update = update
|
||||||
|
}
|
||||||
|
transaction := &tuicRelayTransaction{states: states}
|
||||||
|
if err := replaceTuicRelaySet(api, changes); err != nil {
|
||||||
|
rollbackErr := transaction.rollback(api, changes)
|
||||||
|
return transaction, errors.Join(fmt.Errorf("TUIC relay update: %w", err), rollbackErr)
|
||||||
|
}
|
||||||
|
return transaction, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTuicRelaySet(api *xray.XrayAPI, changes []xray.InboundReplacement) error {
|
||||||
|
removed := make(map[string]bool)
|
||||||
|
for _, change := range changes {
|
||||||
|
inbound := change.Previous
|
||||||
|
if inbound == nil {
|
||||||
|
inbound = change.Next
|
||||||
|
}
|
||||||
|
if inbound == nil || removed[inbound.Tag] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := api.DelInbound(inbound.Tag); err != nil && !xray.IsMissingHandlerErr(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
removed[inbound.Tag] = true
|
||||||
|
}
|
||||||
|
for _, change := range changes {
|
||||||
|
if change.Next == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(change.Next)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := api.AddInbound(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := verifyTuicRelay(change.Previous, change.Next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTuicRelay(previous, next *xray.InboundConfig) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return verifyTuicRelayContext(ctx, previous, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTuicRelayContext(ctx context.Context, previous, next *xray.InboundConfig) error {
|
||||||
|
var settings struct {
|
||||||
|
Auth string `json:"auth"`
|
||||||
|
Accounts []relayAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(next.Settings, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if settings.Auth != "password" || string(next.Listen) != `"127.0.0.1"` {
|
||||||
|
return errors.New("TUIC relay must require authentication on loopback")
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(next.Port))
|
||||||
|
wanted := make(map[string]string, len(settings.Accounts))
|
||||||
|
for _, account := range settings.Accounts {
|
||||||
|
wanted[account.User] = account.Pass
|
||||||
|
}
|
||||||
|
unknown := "_tuic_probe_" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
for wanted[unknown] != "" {
|
||||||
|
unknown += "_"
|
||||||
|
}
|
||||||
|
accepted, err := probeRelayAccount(ctx, addr, relayAccount{User: unknown, Pass: "invalid"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if accepted {
|
||||||
|
return errors.New("TUIC relay accepted an unknown account")
|
||||||
|
}
|
||||||
|
for _, account := range settings.Accounts {
|
||||||
|
accepted, err := probeRelayAccount(ctx, addr, account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !accepted {
|
||||||
|
return errors.New("TUIC relay did not install every configured account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if previous != nil {
|
||||||
|
var old struct {
|
||||||
|
Accounts []relayAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(previous.Settings, &old); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, account := range old.Accounts {
|
||||||
|
// A rejected replacement can contain credentials SOCKS5 cannot encode.
|
||||||
|
// Such accounts cannot authenticate and need no removal probe on rollback.
|
||||||
|
if len(account.User) == 0 || len(account.User) > 255 || len(account.Pass) == 0 || len(account.Pass) > 255 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if password, exists := wanted[account.User]; exists && password == account.Pass {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accepted, err := probeRelayAccount(ctx, addr, account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if accepted {
|
||||||
|
return errors.New("TUIC relay retained a removed account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeRelayAccount(ctx context.Context, addr string, account relayAccount) (bool, error) {
|
||||||
|
if len(account.User) == 0 || len(account.User) > 255 || len(account.Pass) == 0 || len(account.Pass) > 255 {
|
||||||
|
return false, errors.New("TUIC relay credentials exceed SOCKS5 bounds")
|
||||||
|
}
|
||||||
|
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Probes carry no relayed data; abortive close avoids TIME_WAIT per account.
|
||||||
|
_ = conn.(*net.TCPConn).SetLinger(0)
|
||||||
|
defer conn.Close()
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
_ = conn.SetDeadline(deadline)
|
||||||
|
if _, err := conn.Write([]byte{5, 1, 2}); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var response [2]byte
|
||||||
|
if _, err := io.ReadFull(conn, response[:]); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if response != [2]byte{5, 2} {
|
||||||
|
return false, errors.New("TUIC relay did not require password authentication")
|
||||||
|
}
|
||||||
|
request := append([]byte{1, byte(len(account.User))}, account.User...)
|
||||||
|
request = append(request, byte(len(account.Pass)))
|
||||||
|
request = append(request, account.Pass...)
|
||||||
|
if _, err := conn.Write(request); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(conn, response[:]); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if response[0] != 1 {
|
||||||
|
return false, errors.New("TUIC relay returned an invalid authentication response")
|
||||||
|
}
|
||||||
|
return response[1] == 0, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTuicClientAddRejectsOversizeEmailBeforeWriting(t *testing.T) {
|
||||||
|
for _, email := range []string{strings.Repeat("a", 256), strings.Repeat("я", 128)} {
|
||||||
|
t.Run(email[:2], func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inbound := mkInbound(t, 45421, model.TUIC, `{"clients":[]}`)
|
||||||
|
client := model.Client{Email: email, ID: uuid.NewString(), Password: "secret", Enable: true}
|
||||||
|
payload := &model.Inbound{Id: inbound.Id, Settings: clientsSettings(t, []model.Client{client})}
|
||||||
|
_, err := (&ClientService{}).AddInboundClient(&InboundService{}, payload)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "255") {
|
||||||
|
t.Fatalf("oversized TUIC email was not rejected at save: %v", err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", email).Count(&count).Error; err != nil || count != 0 {
|
||||||
|
t.Fatalf("rejected client was persisted: count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicLegacyOversizeEmailDoesNotDisableItsHealthySibling(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
clients := []model.Client{
|
||||||
|
{Email: strings.Repeat("x", 256), ID: uuid.NewString(), Password: "long", Enable: true},
|
||||||
|
{Email: "healthy@test", ID: uuid.NewString(), Password: "short", Enable: true},
|
||||||
|
}
|
||||||
|
inbound := mkInbound(t, 45422, model.TUIC, clientsSettings(t, clients))
|
||||||
|
cfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var accounts []relayAccount
|
||||||
|
for _, relay := range cfg.InboundConfigs {
|
||||||
|
if relay.TuicInboundID != inbound.Id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var settings struct {
|
||||||
|
Accounts []relayAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(relay.Settings, &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accounts = settings.Accounts
|
||||||
|
}
|
||||||
|
if len(accounts) != 1 || accounts[0].User != "healthy@test" {
|
||||||
|
t.Fatalf("legacy invalid account contaminated the relay: got %d accounts", len(accounts))
|
||||||
|
}
|
||||||
|
instances, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil || len(instances) != 1 || len(instances[0].Clients) != 1 || instances[0].Clients[0].Email != "healthy@test" {
|
||||||
|
t.Fatalf("native admission disagrees with relay accounts: %+v err=%v", instances, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicSharedRenameCannotBypassEmailLimitWithInboundFilter(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &ClientService{}
|
||||||
|
client := model.Client{Email: "shared@test", ID: uuid.NewString(), Password: "secret", SubID: "shared-sub", Enable: true}
|
||||||
|
tuicInbound := mkInbound(t, 45423, model.TUIC, clientsSettings(t, []model.Client{client}))
|
||||||
|
vless := mkInbound(t, 45424, model.VLESS, clientsSettings(t, []model.Client{client}))
|
||||||
|
for _, inbound := range []*model.Inbound{tuicInbound, vless} {
|
||||||
|
if err := svc.SyncInbound(nil, inbound.Id, []model.Client{client}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stored := lookupClientRecord(t, client.Email)
|
||||||
|
updated := client
|
||||||
|
updated.Email = strings.Repeat("я", 128)
|
||||||
|
_, err := svc.Update(&InboundService{}, stored.Id, updated, 0, vless.Id)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "255") {
|
||||||
|
t.Fatalf("filtered VLESS edit bypassed the attached TUIC email limit: %v", err)
|
||||||
|
}
|
||||||
|
var current model.ClientRecord
|
||||||
|
if err := database.GetDB().First(¤t, stored.Id).Error; err != nil || current.Email != client.Email {
|
||||||
|
t.Fatalf("rejected shared rename mutated the identity: email=%q err=%v", current.Email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicEmailBoundaryAndOtherProtocolsRemainUsable(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
protocol model.Protocol
|
||||||
|
email string
|
||||||
|
}{
|
||||||
|
{model.TUIC, strings.Repeat("a", 255)},
|
||||||
|
{model.TUIC, strings.Repeat("я", 127) + "a"},
|
||||||
|
{model.VLESS, strings.Repeat("a", 256)},
|
||||||
|
} {
|
||||||
|
t.Run(string(tc.protocol)+"-"+tc.email[:2], func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
inbound := mkInbound(t, 45425, tc.protocol, `{"clients":[]}`)
|
||||||
|
client := model.Client{Email: tc.email, ID: uuid.NewString(), Password: "secret", Enable: true}
|
||||||
|
_, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{Id: inbound.Id, Settings: clientsSettings(t, []model.Client{client})})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stored := lookupClientRecord(t, tc.email)
|
||||||
|
if stored.Email != tc.email || stored.UUID != client.ID {
|
||||||
|
t.Fatal("accepted client identity was not preserved")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/core"
|
||||||
|
"github.com/xtls/xray-core/infra/conf"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isolationRelay(t *testing.T, id int, email string) xray.InboundConfig {
|
||||||
|
t.Helper()
|
||||||
|
return xray.InboundConfig{
|
||||||
|
Tag: fmt.Sprintf("isolation-%d", id), Protocol: "socks", Listen: mustJSON(t, "127.0.0.1"),
|
||||||
|
Port: tuic.SOCKSPortForInbound(id), TuicInboundID: id, TuicRelay: true,
|
||||||
|
Settings: mustJSON(t, map[string]any{
|
||||||
|
"auth": "password", "udp": true,
|
||||||
|
"accounts": []relayAccount{{User: email, Pass: tuic.SocksPassword()}},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolationConfig(t *testing.T, relays ...xray.InboundConfig) *xray.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := (balancerTestConfig{outbounds: allBalancerTestOutbounds}).build(t, freeLoopbackPort(t))
|
||||||
|
cfg.InboundConfigs = append(cfg.InboundConfigs, relays...)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func startIsolationCore(t *testing.T, cfg *xray.Config) *xray.XrayAPI {
|
||||||
|
t.Helper()
|
||||||
|
var config conf.Config
|
||||||
|
if err := json.Unmarshal(mustJSON(t, cfg), &config); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
built, err := config.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
instance, err := core.New(built)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = instance.Close() })
|
||||||
|
if err := instance.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
api := &xray.XrayAPI{}
|
||||||
|
if err := api.Init(cfg.InboundConfigs[0].Port); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(api.Close)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertIsolationRelay(t *testing.T, relay xray.InboundConfig, email string) {
|
||||||
|
t.Helper()
|
||||||
|
marker := startTuicRouteMarker(t, "ready")
|
||||||
|
_, port, _ := net.SplitHostPort(marker)
|
||||||
|
number, _ := strconv.Atoi(port)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
bridge := &tuic.SocksRelay{
|
||||||
|
InboundID: relay.TuicInboundID,
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", relay.Port), Password: tuic.SocksPassword(),
|
||||||
|
}
|
||||||
|
if relay.TuicRelay {
|
||||||
|
bridge.Gate = tuic.ManagedRelayGate(relay.Port)
|
||||||
|
}
|
||||||
|
conn, err := bridge.DialTCP(ctx, email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: uint16(number)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("healthy relay %s rejected its client: %v", relay.Tag, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(time.Second))
|
||||||
|
if _, err := conn.Write([]byte(tuicRoutingProbe)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := make([]byte, len("ready:"+tuicRoutingProbe))
|
||||||
|
if _, err := io.ReadFull(conn, response); err != nil || string(response) != "ready:"+tuicRoutingProbe {
|
||||||
|
t.Fatalf("relay response = %q, err=%v", response, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicReadinessFailureDoesNotStrandLaterRelays(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
bad := isolationRelay(t, 960181, strings.Repeat("x", 256))
|
||||||
|
good := isolationRelay(t, 960182, "new-after-restart@test")
|
||||||
|
cfg := isolationConfig(t, bad, good)
|
||||||
|
startIsolationCore(t, cfg)
|
||||||
|
for _, warm := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("warm=%t", warm), func(t *testing.T) {
|
||||||
|
gate := tuic.ManagedRelayGate(good.Port)
|
||||||
|
update, err := gate.BeginUpdate(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
update.Finish(good.TuicInboundID, []string{"old-before-restart@test"}, warm)
|
||||||
|
err = publishTuicRelayReadiness(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("invalid relay error = %v", err)
|
||||||
|
}
|
||||||
|
assertIsolationRelay(t, good, "new-after-restart@test")
|
||||||
|
if !isNeedXrayReconcile.Load() {
|
||||||
|
t.Fatal("failed readiness check did not schedule reconciliation")
|
||||||
|
}
|
||||||
|
gate.Invalidate(good.TuicInboundID)
|
||||||
|
if err := repairUnavailableTuicRelays(cfg, cfg.InboundConfigs[0].Port); err == nil {
|
||||||
|
t.Fatal("repair hid the invalid relay")
|
||||||
|
}
|
||||||
|
assertIsolationRelay(t, good, "new-after-restart@test")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicMissingRelayDoesNotDelayHealthyReadiness(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
missing, healthy := isolationRelay(t, 960189, "missing@test"), isolationRelay(t, 960190, "ready@test")
|
||||||
|
cfg := isolationConfig(t, healthy)
|
||||||
|
startIsolationCore(t, cfg)
|
||||||
|
wanted := *cfg
|
||||||
|
wanted.InboundConfigs = []xray.InboundConfig{cfg.InboundConfigs[0], missing, healthy}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- publishTuicRelayBatch(ctx, &wanted) }()
|
||||||
|
defer func() { cancel(); <-done }()
|
||||||
|
assertIsolationRelay(t, healthy, "ready@test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicRestartInvalidatesOldAccountsEvenWhenVerificationCannotStart(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
healthy := isolationRelay(t, 960196, "new@test")
|
||||||
|
cfg := isolationConfig(t, healthy)
|
||||||
|
startIsolationCore(t, cfg)
|
||||||
|
gate := tuic.ManagedRelayGate(healthy.Port)
|
||||||
|
update, err := gate.BeginUpdate(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
update.Finish(healthy.TuicInboundID, []string{"old@test"}, true)
|
||||||
|
wanted := *cfg
|
||||||
|
wanted.InboundConfigs = []xray.InboundConfig{cfg.InboundConfigs[0]}
|
||||||
|
for id := 960192; id <= 960195; id++ {
|
||||||
|
wanted.InboundConfigs = append(wanted.InboundConfigs, isolationRelay(t, id, "missing@test"))
|
||||||
|
}
|
||||||
|
wanted.InboundConfigs = append(wanted.InboundConfigs, healthy)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := publishTuicRelayBatch(ctx, &wanted); err == nil {
|
||||||
|
t.Fatal("missing relays were published as ready")
|
||||||
|
}
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
bridge := &tuic.SocksRelay{
|
||||||
|
Gate: gate, InboundID: healthy.TuicInboundID,
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", healthy.Port), Password: tuic.SocksPassword(),
|
||||||
|
}
|
||||||
|
conn, err := bridge.DialTCP(ctx, "old@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("stale account crossed the gate after restart: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicFailedSwapDoesNotBlockOtherRelayOrRouting(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
first, second := isolationRelay(t, 960183, "first@test"), isolationRelay(t, 960184, "second@test")
|
||||||
|
before := isolationConfig(t, first, second)
|
||||||
|
api := startIsolationCore(t, before)
|
||||||
|
if err := publishTuicRelayReadiness(before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := *before
|
||||||
|
after.InboundConfigs = append([]xray.InboundConfig(nil), before.InboundConfigs...)
|
||||||
|
after.InboundConfigs[1] = isolationRelay(t, first.TuicInboundID, strings.Repeat("x", 256))
|
||||||
|
after.InboundConfigs[2] = isolationRelay(t, second.TuicInboundID, "updated@test")
|
||||||
|
var router map[string]any
|
||||||
|
if err := json.Unmarshal(before.RouterConfig, &router); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "port": "9999", "outboundTag": "blocked"})
|
||||||
|
after.RouterConfig = mustJSON(t, router)
|
||||||
|
process := xray.NewTestProcess(before, filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
applied, err := (&XrayService{}).tryHotApply(process, &after)
|
||||||
|
if applied || err == nil || !strings.Contains(err.Error(), "SOCKS5 bounds") {
|
||||||
|
t.Fatalf("partial apply did not report its deferred relay: applied=%t err=%v", applied, err)
|
||||||
|
}
|
||||||
|
route, err := api.TestRoute(xray.RouteTestRequest{IP: "203.0.113.1", Port: 9999, Network: "tcp"})
|
||||||
|
if err != nil || route.OutboundTag != "blocked" {
|
||||||
|
t.Fatalf("failed TUIC relay prevented unrelated routing update: route=%+v err=%v", route, err)
|
||||||
|
}
|
||||||
|
assertIsolationRelay(t, first, "first@test")
|
||||||
|
assertIsolationRelay(t, second, "updated@test")
|
||||||
|
if !process.GetConfig().InboundConfigs[1].Equals(&first) || !process.GetConfig().InboundConfigs[2].Equals(&after.InboundConfigs[2]) || string(process.GetConfig().RouterConfig) != string(after.RouterConfig) {
|
||||||
|
t.Fatal("running snapshot lost successful updates or recorded the failed relay as applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicRelayTagSwapKeepsBothOwnersReachable(t *testing.T) {
|
||||||
|
first, second := isolationRelay(t, 960185, "first@test"), isolationRelay(t, 960186, "second@test")
|
||||||
|
before := isolationConfig(t, first, second)
|
||||||
|
api := startIsolationCore(t, before)
|
||||||
|
if err := publishTuicRelayReadiness(before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := *before
|
||||||
|
after.InboundConfigs = append([]xray.InboundConfig(nil), before.InboundConfigs...)
|
||||||
|
after.InboundConfigs[1].Tag, after.InboundConfigs[2].Tag = second.Tag, first.Tag
|
||||||
|
diff, hot := xray.ComputeHotDiff(before, &after)
|
||||||
|
if !hot {
|
||||||
|
t.Fatal("relay tag swap cannot be hot applied")
|
||||||
|
}
|
||||||
|
if err := applyTuicRelayReplacements(api, diff.TuicRelayChanges); err != nil {
|
||||||
|
t.Fatalf("tag swap collided with a listener in its own transaction: %v", err)
|
||||||
|
}
|
||||||
|
assertIsolationRelay(t, after.InboundConfigs[1], "first@test")
|
||||||
|
assertIsolationRelay(t, after.InboundConfigs[2], "second@test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicFailedRenameDefersDependentRoutingButAddsOtherInbound(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
previous := isolationRelay(t, 960187, "original@test")
|
||||||
|
second := isolationRelay(t, 960198, "old-second@test")
|
||||||
|
before := isolationConfig(t, previous, second)
|
||||||
|
api := startIsolationCore(t, before)
|
||||||
|
if err := publishTuicRelayReadiness(before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := *before
|
||||||
|
next := isolationRelay(t, previous.TuicInboundID, strings.Repeat("x", 256))
|
||||||
|
next.Tag = "renamed-unavailable"
|
||||||
|
ordinary := isolationRelay(t, 960188, "other@test")
|
||||||
|
ordinary.TuicRelay = false
|
||||||
|
ordinary.TuicInboundID = 0
|
||||||
|
ordinary.Port = freeLoopbackPort(t)
|
||||||
|
ordinary.Settings = mustJSON(t, map[string]any{"auth": "noauth", "udp": true})
|
||||||
|
dependent := ordinary
|
||||||
|
dependent.Tag = "dependent-ordinary"
|
||||||
|
dependent.Port = freeLoopbackPort(t)
|
||||||
|
updatedSecond := isolationRelay(t, second.TuicInboundID, "new-second@test")
|
||||||
|
after.InboundConfigs = []xray.InboundConfig{before.InboundConfigs[0], next, updatedSecond, ordinary, dependent}
|
||||||
|
var policy map[string]any
|
||||||
|
if err := json.Unmarshal(before.RouterConfig, &policy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy["rules"] = append(policy["rules"].([]any),
|
||||||
|
map[string]any{"type": "field", "inboundTag": []string{next.Tag}, "outboundTag": "blocked"},
|
||||||
|
map[string]any{"type": "field", "inboundTag": []string{dependent.Tag}, "outboundTag": "blocked"},
|
||||||
|
map[string]any{"type": "field", "inboundTag": []string{second.Tag}, "outboundTag": "blocked"},
|
||||||
|
)
|
||||||
|
after.RouterConfig = mustJSON(t, policy)
|
||||||
|
process := xray.NewTestProcess(before, filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if applied, err := (&XrayService{}).tryHotApply(process, &after); applied || err == nil || !strings.Contains(err.Error(), "SOCKS5 bounds") {
|
||||||
|
t.Fatalf("failed rename result = %t, %v", applied, err)
|
||||||
|
}
|
||||||
|
assertIsolationRelay(t, previous, "original@test")
|
||||||
|
assertIsolationRelay(t, second, "old-second@test")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
secondBridge := &tuic.SocksRelay{
|
||||||
|
Gate: tuic.ManagedRelayGate(second.Port), InboundID: second.TuicInboundID,
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", second.Port), Password: tuic.SocksPassword(),
|
||||||
|
}
|
||||||
|
pending, err := secondBridge.DialTCP(ctx, "new-second@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
|
||||||
|
if pending != nil {
|
||||||
|
_ = pending.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("new TUIC account escaped its deferred routing policy: %v", err)
|
||||||
|
}
|
||||||
|
assertIsolationRelay(t, ordinary, "other@test")
|
||||||
|
reservation, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", dependent.Port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new inbound started before its dependent routing was applied: %v", err)
|
||||||
|
}
|
||||||
|
_ = reservation.Close()
|
||||||
|
route, err := api.TestRoute(xray.RouteTestRequest{InboundTag: next.Tag, IP: "203.0.113.1", Port: 6697})
|
||||||
|
if err != nil || route.Matched {
|
||||||
|
t.Fatalf("routing depending on an uninstalled relay was published: route=%+v err=%v", route, err)
|
||||||
|
}
|
||||||
|
if string(process.GetConfig().RouterConfig) != string(before.RouterConfig) {
|
||||||
|
t.Fatal("snapshot recorded deferred routing as applied")
|
||||||
|
}
|
||||||
|
after.InboundConfigs[1] = previous
|
||||||
|
after.InboundConfigs[1].Tag = next.Tag
|
||||||
|
if applied, err := (&XrayService{}).tryHotApply(process, &after); !applied || err != nil {
|
||||||
|
t.Fatalf("corrected rename did not converge: applied=%t err=%v", applied, err)
|
||||||
|
}
|
||||||
|
route, err = api.TestRoute(xray.RouteTestRequest{InboundTag: next.Tag, IP: "203.0.113.1", Port: 6697})
|
||||||
|
if err != nil || route.OutboundTag != "blocked" || !process.GetConfig().Equals(&after) {
|
||||||
|
t.Fatalf("deferred routing did not converge after repair: route=%+v err=%v", route, err)
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", dependent.Port), time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dependent inbound did not converge after routing recovery: %v", err)
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicFailedRenameStillAppliesUnrelatedRouting(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
previous := isolationRelay(t, 960191, "original@test")
|
||||||
|
before := isolationConfig(t, previous)
|
||||||
|
api := startIsolationCore(t, before)
|
||||||
|
if err := publishTuicRelayReadiness(before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := *before
|
||||||
|
next := isolationRelay(t, previous.TuicInboundID, strings.Repeat("x", 256))
|
||||||
|
next.Tag = "renamed-failed"
|
||||||
|
after.InboundConfigs = []xray.InboundConfig{before.InboundConfigs[0], next}
|
||||||
|
var router map[string]any
|
||||||
|
if err := json.Unmarshal(before.RouterConfig, &router); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "port": "9999", "outboundTag": "blocked"})
|
||||||
|
after.RouterConfig = mustJSON(t, router)
|
||||||
|
process := xray.NewTestProcess(before, filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if _, err := (&XrayService{}).tryHotApply(process, &after); err == nil || !strings.Contains(err.Error(), "SOCKS5 bounds") {
|
||||||
|
t.Fatalf("failed rename was not reported: %v", err)
|
||||||
|
}
|
||||||
|
route, err := api.TestRoute(xray.RouteTestRequest{IP: "203.0.113.1", Port: 9999})
|
||||||
|
if err != nil || route.OutboundTag != "blocked" {
|
||||||
|
t.Fatalf("failed rename blocked an unrelated rule: route=%+v err=%v", route, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTuicFailedReconcileRetriesWithoutRequestingCoreRestart(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", `{broken`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wasStopped := isManuallyStopped.Swap(false)
|
||||||
|
wasRestart := isNeedXrayRestart.Swap(false)
|
||||||
|
wasReconcile := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
isManuallyStopped.Store(wasStopped)
|
||||||
|
isNeedXrayRestart.Store(wasRestart)
|
||||||
|
isNeedXrayReconcile.Store(wasReconcile)
|
||||||
|
})
|
||||||
|
svc := &XrayService{}
|
||||||
|
rt := runtime.NewLocal(runtime.LocalDeps{SetNeedRestart: svc.SetToNeedRestart, RequestReconcile: svc.SetToNeedReconcile})
|
||||||
|
inbound := &model.Inbound{Id: 940004, Protocol: model.TUIC}
|
||||||
|
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc.ApplyPendingRestart()
|
||||||
|
if isNeedXrayRestart.Load() || !isNeedXrayReconcile.Load() {
|
||||||
|
t.Fatalf("failed TUIC reconciliation became a core restart or lost retry: restart=%t reconcile=%t", isNeedXrayRestart.Load(), isNeedXrayReconcile.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicQuotaResetDoesNotChangeRelayAdmission(t *testing.T) {
|
||||||
|
for _, reset := range []string{"telegram", "scheduled"} {
|
||||||
|
t.Run(reset, func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
client := model.Client{Email: "renewed@test", ID: uuid.NewString(), Password: "secret", Enable: true}
|
||||||
|
ib := mkInbound(t, 45381, model.TUIC, clientsSettings(t, []model.Client{client}))
|
||||||
|
svc := &InboundService{}
|
||||||
|
if err := (&ClientService{}).SyncInbound(nil, ib.Id, []model.Client{client}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.AddClientStat(database.GetDB(), ib.Id, &client); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", client.Email).Update("enable", false).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reset == "telegram" {
|
||||||
|
err = svc.ResetClientTrafficByEmail(client.Email)
|
||||||
|
} else {
|
||||||
|
_, err = (&ClientService{}).ResetAllClientTraffics(svc, ib.Id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !before.Equals(after) {
|
||||||
|
t.Fatal("quota reset changed Xray relay admission; the renewed TUIC client needs an unrelated Xray reload")
|
||||||
|
}
|
||||||
|
instances, err := svc.DesiredTuicInstances()
|
||||||
|
if err != nil || len(instances) != 1 || len(instances[0].Clients) != 1 || instances[0].Clients[0].Email != client.Email {
|
||||||
|
t.Fatalf("renewed client missing from native admission: %+v, %v", instances, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicClientChurnCanApplyWithoutRestartingOtherProtocols(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
clients := []model.Client{{Email: "first@test", ID: uuid.NewString(), Password: "secret", Enable: true}}
|
||||||
|
ib := mkInbound(t, 45382, model.TUIC, clientsSettings(t, clients))
|
||||||
|
svc := &XrayService{}
|
||||||
|
if err := (&SettingService{}).SetRestartXrayOnClientDisable(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := svc.GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clients = append(clients, model.Client{Email: "новый@test", ID: uuid.NewString(), Password: "second", Enable: true})
|
||||||
|
for _, step := range []string{"add", "remove", "empty"} {
|
||||||
|
switch step {
|
||||||
|
case "remove":
|
||||||
|
clients = clients[1:]
|
||||||
|
case "empty":
|
||||||
|
clients = nil
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Model(ib).Update("settings", clientsSettings(t, clients)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := svc.GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
diff, hot := xray.ComputeHotDiff(before, after)
|
||||||
|
if !hot || diff.Empty() || svc.restartToDropClients(diff) {
|
||||||
|
t.Fatalf("TUIC %s would restart unrelated protocols: hot=%t diff=%+v", step, hot, diff)
|
||||||
|
}
|
||||||
|
before = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrokenTuicInboundDoesNotBlockOtherInbounds(t *testing.T) {
|
||||||
|
for _, fault := range []string{"invalid-settings", "invalid-shape", "template-tag"} {
|
||||||
|
t.Run(fault, func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
ordinary := mkInbound(t, 45383, model.VLESS, `{"decryption":"none","clients":[]}`)
|
||||||
|
broken := mkInbound(t, 45384, model.TUIC, clientsSettings(t, []model.Client{{Email: "broken@test", ID: uuid.NewString(), Password: "secret", Enable: true}}))
|
||||||
|
switch fault {
|
||||||
|
case "invalid-settings":
|
||||||
|
broken.Settings = `{broken`
|
||||||
|
case "invalid-shape":
|
||||||
|
broken.Settings = `{"server":"broken"}`
|
||||||
|
case "template-tag":
|
||||||
|
broken.Tag = "template-control"
|
||||||
|
template := map[string]any{"inbounds": []any{map[string]any{"tag": broken.Tag, "listen": "127.0.0.1", "port": 45385, "protocol": "vless", "settings": map[string]any{"decryption": "none", "clients": []any{}}}}, "outbounds": []any{map[string]any{"protocol": "freedom", "tag": "direct"}}}
|
||||||
|
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", string(mustJSON(t, template))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Save(broken).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("one broken TUIC row blocked all protocols: %v", err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, inbound := range cfg.InboundConfigs {
|
||||||
|
if inbound.Tag == ordinary.Tag {
|
||||||
|
count++
|
||||||
|
if inbound.Protocol != "vless" || inbound.Port != ordinary.Port {
|
||||||
|
t.Fatalf("TUIC replaced the unrelated inbound: %+v", inbound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("unrelated inbound appears %d times, want once", count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicCannotSaveTemplateReservedTag(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
template := `{"api":{"tag":"management"},"inbounds":[{"tag":"template-control","listen":"127.0.0.1","port":45385,"protocol":"vless","settings":{"decryption":"none","clients":[]}}],"outbounds":[{"protocol":"freedom","tag":"direct"}]}`
|
||||||
|
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, tag := range []string{"api", "management", "template-control"} {
|
||||||
|
inbound := &model.Inbound{Tag: tag, Protocol: model.TUIC, Listen: "127.0.0.1", Port: 45390 + i, Settings: `{"clients":[]}`}
|
||||||
|
saved, _, err := (&InboundService{}).AddInbound(inbound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stored model.Inbound
|
||||||
|
if err := database.GetDB().First(&stored, saved.Id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Tag == tag || stored.Tag != saved.Tag {
|
||||||
|
t.Fatalf("reserved tag %q was accepted or API returned a different tag: saved=%q stored=%q", tag, saved.Tag, stored.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xtls/xray-core/app/proxyman/command"
|
||||||
|
routercommand "github.com/xtls/xray-core/app/router/command"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
)
|
||||||
|
|
||||||
|
type delayedTuicRouting struct {
|
||||||
|
routercommand.UnimplementedRoutingServiceServer
|
||||||
|
client routercommand.RoutingServiceClient
|
||||||
|
entered, resume chan struct{}
|
||||||
|
reject atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *delayedTuicRouting) AddRule(ctx context.Context, request *routercommand.AddRuleRequest) (*routercommand.AddRuleResponse, error) {
|
||||||
|
if s.reject.Load() {
|
||||||
|
return nil, status.Error(codes.Unavailable, "controlled routing failure")
|
||||||
|
}
|
||||||
|
close(s.entered)
|
||||||
|
select {
|
||||||
|
case <-s.resume:
|
||||||
|
return s.client.AddRule(ctx, request)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTuicNewAccountWaitsForItsRoutingPolicy(t *testing.T) {
|
||||||
|
previous := isolationRelay(t, 960197, "old@test")
|
||||||
|
before := isolationConfig(t, previous)
|
||||||
|
api := startIsolationCore(t, before)
|
||||||
|
if err := publishTuicRelayReadiness(before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", before.InboundConfigs[0].Port), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := grpc.NewServer()
|
||||||
|
routing := &delayedTuicRouting{client: routercommand.NewRoutingServiceClient(conn), entered: make(chan struct{}), resume: make(chan struct{})}
|
||||||
|
ready := make(chan struct{})
|
||||||
|
close(ready)
|
||||||
|
command.RegisterHandlerServiceServer(server, &delayedTuicHandler{client: command.NewHandlerServiceClient(conn), entered: make(chan struct{}), resume: ready})
|
||||||
|
routercommand.RegisterRoutingServiceServer(server, routing)
|
||||||
|
go func() { _ = server.Serve(listener) }()
|
||||||
|
defer server.Stop()
|
||||||
|
// The proxy exposes the real core API while holding only the routing reload.
|
||||||
|
snapshot := *before
|
||||||
|
snapshot.InboundConfigs = append([]xray.InboundConfig(nil), before.InboundConfigs...)
|
||||||
|
snapshot.InboundConfigs[0].Port = listener.Addr().(*net.TCPAddr).Port
|
||||||
|
after := snapshot
|
||||||
|
after.InboundConfigs = append([]xray.InboundConfig(nil), snapshot.InboundConfigs...)
|
||||||
|
after.InboundConfigs[1] = isolationRelay(t, previous.TuicInboundID, "new@test")
|
||||||
|
var router map[string]any
|
||||||
|
if err := json.Unmarshal(before.RouterConfig, &router); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "user": []string{"new@test"}, "outboundTag": "blocked"})
|
||||||
|
after.RouterConfig = mustJSON(t, router)
|
||||||
|
process := xray.NewTestProcess(&snapshot, filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
done := make(chan error, 1)
|
||||||
|
completed := make(chan struct{})
|
||||||
|
go func() { defer close(completed); _, err := (&XrayService{}).tryHotApply(process, &after); done <- err }()
|
||||||
|
var release sync.Once
|
||||||
|
resume := func() { release.Do(func() { close(routing.resume) }) }
|
||||||
|
defer func() { resume(); <-completed }()
|
||||||
|
select {
|
||||||
|
case <-routing.entered:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("routing reload was not reached")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
bridge := &tuic.SocksRelay{Gate: tuic.ManagedRelayGate(previous.Port), InboundID: previous.TuicInboundID, Addr: fmt.Sprintf("127.0.0.1:%d", previous.Port), Password: tuic.SocksPassword()}
|
||||||
|
tunnel, err := bridge.DialTCP(ctx, "new@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
|
||||||
|
if tunnel != nil {
|
||||||
|
_ = tunnel.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("new account was admitted before its routing policy: %v", err)
|
||||||
|
}
|
||||||
|
resume()
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := api.TestRoute(xray.RouteTestRequest{Email: "new@test", IP: "203.0.113.1", Port: 6697})
|
||||||
|
if err != nil || result.OutboundTag != "blocked" {
|
||||||
|
t.Fatalf("new routing policy not installed: result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
routing.reject.Store(true)
|
||||||
|
failed := after
|
||||||
|
failed.InboundConfigs = append([]xray.InboundConfig(nil), after.InboundConfigs...)
|
||||||
|
failed.InboundConfigs[1] = isolationRelay(t, previous.TuicInboundID, "failed@test")
|
||||||
|
router["rules"] = append(router["rules"].([]any), map[string]any{"type": "field", "user": []string{"failed@test"}, "outboundTag": "blocked"})
|
||||||
|
failed.RouterConfig = mustJSON(t, router)
|
||||||
|
if applied, err := (&XrayService{}).tryHotApply(process, &failed); applied || err != nil {
|
||||||
|
t.Fatalf("ordinary routing failure should request a restart fallback: applied=%t err=%v", applied, err)
|
||||||
|
}
|
||||||
|
if !process.GetConfig().Equals(&after) {
|
||||||
|
t.Fatal("failed routing reload advanced the running snapshot")
|
||||||
|
}
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
tunnel, err = bridge.DialTCP(ctx, "failed@test", &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(127, 0, 0, 1), Port: 9})
|
||||||
|
if tunnel != nil {
|
||||||
|
_ = tunnel.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("routing failure left the new relay account admitted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/xtls/xray-core/app/proxyman/command"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type delayedTuicHandler struct {
|
||||||
|
command.UnimplementedHandlerServiceServer
|
||||||
|
client command.HandlerServiceClient
|
||||||
|
entered chan struct{}
|
||||||
|
resume chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
reject atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *delayedTuicHandler) AddInbound(ctx context.Context, req *command.AddInboundRequest) (*command.AddInboundResponse, error) {
|
||||||
|
s.once.Do(func() { close(s.entered) })
|
||||||
|
select {
|
||||||
|
case <-s.resume:
|
||||||
|
if s.reject.Load() {
|
||||||
|
return nil, status.Error(codes.Unavailable, "controlled relay add failure")
|
||||||
|
}
|
||||||
|
return s.client.AddInbound(ctx, req)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *delayedTuicHandler) RemoveInbound(ctx context.Context, req *command.RemoveInboundRequest) (*command.RemoveInboundResponse, error) {
|
||||||
|
return s.client.RemoveInbound(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTuicReplacementWindow(t *testing.T, cfg *xray.Config, apiPort int, inbound *model.Inbound, native *tuic.Server, clients []model.Client) {
|
||||||
|
t.Helper()
|
||||||
|
var previous xray.InboundConfig
|
||||||
|
for _, candidate := range cfg.InboundConfigs {
|
||||||
|
if candidate.Tag == inbound.Tag {
|
||||||
|
previous = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := previous
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal(previous.Settings, &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accounts := settings["accounts"].([]any)
|
||||||
|
settings["accounts"] = append(accounts, map[string]any{"user": "window@test", "pass": tuic.SocksPassword()})
|
||||||
|
next.Settings = mustJSON(t, settings)
|
||||||
|
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", apiPort), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
proxy := &delayedTuicHandler{client: command.NewHandlerServiceClient(conn), entered: make(chan struct{}), resume: make(chan struct{})}
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
grpcServer := grpc.NewServer()
|
||||||
|
command.RegisterHandlerServiceServer(grpcServer, proxy)
|
||||||
|
go func() { _ = grpcServer.Serve(listener) }()
|
||||||
|
defer grpcServer.Stop()
|
||||||
|
var resumeOnce sync.Once
|
||||||
|
resume := func() { resumeOnce.Do(func() { close(proxy.resume) }) }
|
||||||
|
defer resume()
|
||||||
|
api := &xray.XrayAPI{}
|
||||||
|
if err := api.Init(listener.Addr().(*net.TCPAddr).Port); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
instances, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil || len(instances) != 1 {
|
||||||
|
t.Fatalf("native instance missing: %+v %v", instances, err)
|
||||||
|
}
|
||||||
|
newClient := model.Client{Email: "window@test", ID: uuid.NewString(), Password: "window-password", Enable: true}
|
||||||
|
native.UpdateUsers(append(append([]tuic.TuicClientSettings(nil), instances[0].Clients...), tuic.TuicClientSettings{
|
||||||
|
UUID: newClient.ID, Email: newClient.Email, Password: newClient.Password,
|
||||||
|
}))
|
||||||
|
defer native.UpdateUsers(instances[0].Clients)
|
||||||
|
newPeer := connectTuicRoutingClient(t, instances[0], newClient)
|
||||||
|
newUserResult := make(chan error, 1)
|
||||||
|
go func() { newUserResult <- exchangeTuicRoute(newPeer, "tcp", "fallback:"+tuicRoutingProbe) }()
|
||||||
|
select {
|
||||||
|
case err := <-newUserResult:
|
||||||
|
t.Fatalf("new account did not wait for relay configuration: %v", err)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
persistent := connectTuicRoutingClient(t, instances[0], clients[1])
|
||||||
|
probeTuicRoute(t, persistent, "native", "user:"+tuicRoutingProbe)
|
||||||
|
tcpClient := connectTuicRoutingClient(t, instances[0], clients[0])
|
||||||
|
streamClient := connectTuicRoutingClient(t, instances[0], clients[2])
|
||||||
|
updated := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
updated <- applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &previous, Next: &next}})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-proxy.entered:
|
||||||
|
case err := <-updated:
|
||||||
|
t.Fatalf("replacement failed before the controlled window: %v", err)
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("replacement did not remove the old listener")
|
||||||
|
}
|
||||||
|
tcpResult, udpResult := make(chan error, 1), make(chan error, 1)
|
||||||
|
go func() { tcpResult <- exchangeTuicRoute(tcpClient, "tcp", "specific:"+tuicRoutingProbe) }()
|
||||||
|
go func() { udpResult <- exchangeTuicRoute(streamClient, "quic", "fallback:"+tuicRoutingProbe) }()
|
||||||
|
var packet bytes.Buffer
|
||||||
|
target := &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697}
|
||||||
|
if err := tuic.WritePacket(&packet, 99, 1, 1, 0, target, []byte(tuicRoutingProbe)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := persistent.SendDatagram(packet.Bytes()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A pending association must not block the same QUIC connection's existing UDP flow.
|
||||||
|
probeTuicRoute(t, persistent, "native", "user:"+tuicRoutingProbe)
|
||||||
|
resume()
|
||||||
|
if err := <-updated; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, result := range []<-chan error{tcpResult, udpResult, newUserResult} {
|
||||||
|
if err := <-result; err != nil {
|
||||||
|
t.Errorf("new TUIC request failed during relay replacement: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
response, err := persistent.ReceiveDatagram(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first packet of the queued native UDP association was lost: %v", err)
|
||||||
|
}
|
||||||
|
reader := bytes.NewReader(response)
|
||||||
|
_, _, err = tuic.ReadCommand(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
header, err := tuic.ReadPacketHeader(reader)
|
||||||
|
body, bodyErr := io.ReadAll(reader)
|
||||||
|
if err != nil || bodyErr != nil || header.AssocID != 99 || string(body) != "user:"+tuicRoutingProbe {
|
||||||
|
t.Fatalf("queued UDP response mismatch: header=%+v body=%q errors=%v/%v", header, body, err, bodyErr)
|
||||||
|
}
|
||||||
|
if err := applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &next, Next: &previous}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
proxy.reject.Store(true)
|
||||||
|
err = applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &previous, Next: &next}})
|
||||||
|
if err == nil || strings.Count(err.Error(), "controlled relay add failure") != 2 {
|
||||||
|
t.Fatalf("relay update and rollback failures were hidden: %v", err)
|
||||||
|
}
|
||||||
|
recovery := make(chan error, 1)
|
||||||
|
go func() { recovery <- exchangeTuicRoute(tcpClient, "tcp", "specific:"+tuicRoutingProbe) }()
|
||||||
|
select {
|
||||||
|
case err := <-recovery:
|
||||||
|
t.Fatalf("failed rollback admitted a request into the missing relay: %v", err)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := repairUnavailableTuicRelays(cfg, apiPort); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := <-recovery; err != nil {
|
||||||
|
t.Fatalf("unavailable relay did not recover with unchanged desired config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/quic-go/quic-go"
|
||||||
|
_ "github.com/xtls/xray-core/app/commander"
|
||||||
|
_ "github.com/xtls/xray-core/app/dispatcher"
|
||||||
|
_ "github.com/xtls/xray-core/app/proxyman/inbound"
|
||||||
|
_ "github.com/xtls/xray-core/app/proxyman/outbound"
|
||||||
|
"github.com/xtls/xray-core/core"
|
||||||
|
"github.com/xtls/xray-core/features/stats"
|
||||||
|
"github.com/xtls/xray-core/infra/conf"
|
||||||
|
_ "github.com/xtls/xray-core/proxy/blackhole"
|
||||||
|
_ "github.com/xtls/xray-core/proxy/dokodemo"
|
||||||
|
_ "github.com/xtls/xray-core/proxy/freedom"
|
||||||
|
_ "github.com/xtls/xray-core/proxy/socks"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tuicRoutingProbe = "tuic-route-probe"
|
||||||
|
|
||||||
|
func TestTuicUserRoutingDoesNotDoubleCountTraffic(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
cert, key := writeTestCertificate(t)
|
||||||
|
udp, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := udp.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
_ = udp.Close()
|
||||||
|
clients := []model.Client{
|
||||||
|
{Email: "клиент@test", ID: uuid.NewString(), Password: "specific-password", Enable: true},
|
||||||
|
{Email: "user@test", ID: uuid.NewString(), Password: "user-password", Enable: true},
|
||||||
|
{Email: "fallback@test", ID: uuid.NewString(), Password: "fallback-password", Enable: true},
|
||||||
|
}
|
||||||
|
settings := map[string]any{"certificate": cert, "private_key": key, "clients": clients, "alpn": []string{"h3"}}
|
||||||
|
ib := mkInbound(t, port, model.TUIC, string(mustJSON(t, settings)))
|
||||||
|
ib.Listen = "127.0.0.1"
|
||||||
|
if err := database.GetDB().Save(ib).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range clients {
|
||||||
|
if err := (&InboundService{}).AddClientStat(database.GetDB(), ib.Id, &c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outbounds := []any{map[string]any{"tag": "blocked", "protocol": "blackhole"}}
|
||||||
|
for _, tag := range []string{"specific", "user", "fallback"} {
|
||||||
|
addr := startTuicRouteMarker(t, tag)
|
||||||
|
outbounds = append(outbounds, map[string]any{"tag": tag, "protocol": "freedom", "settings": map[string]any{"redirect": addr}})
|
||||||
|
}
|
||||||
|
apiPort := freeLoopbackPort(t)
|
||||||
|
template := map[string]any{
|
||||||
|
"api": map[string]any{"tag": "api", "services": []string{"HandlerService"}},
|
||||||
|
"inbounds": []any{map[string]any{"tag": "api", "listen": "127.0.0.1", "port": apiPort, "protocol": "tunnel", "settings": map[string]any{"address": "127.0.0.1"}}},
|
||||||
|
"log": map[string]any{"loglevel": "error", "access": "none"}, "stats": map[string]any{},
|
||||||
|
"outbounds": outbounds,
|
||||||
|
"policy": map[string]any{
|
||||||
|
"levels": map[string]any{
|
||||||
|
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "connIdle": 123},
|
||||||
|
"4294967295": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
|
||||||
|
},
|
||||||
|
"system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true},
|
||||||
|
},
|
||||||
|
"routing": map[string]any{"rules": []any{
|
||||||
|
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
||||||
|
map[string]any{"type": "field", "inboundTag": []string{ib.Tag}, "user": []string{clients[0].Email}, "outboundTag": "specific"},
|
||||||
|
map[string]any{"type": "field", "user": []string{clients[0].Email, clients[1].Email}, "outboundTag": "user"},
|
||||||
|
map[string]any{"type": "field", "inboundTag": []string{ib.Tag}, "outboundTag": "fallback"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", string(mustJSON(t, template))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := (&XrayService{}).GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ordinaryPort := freeLoopbackPort(t)
|
||||||
|
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||||
|
Listen: mustJSON(t, "127.0.0.1"), Port: ordinaryPort, Protocol: "socks", Tag: "ordinary",
|
||||||
|
Settings: mustJSON(t, map[string]any{"auth": "password", "accounts": []any{map[string]any{"user": clients[1].Email, "pass": "ordinary-password"}}}),
|
||||||
|
})
|
||||||
|
var parsed conf.Config
|
||||||
|
if err := json.Unmarshal(mustJSON(t, cfg), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var relaySettings struct {
|
||||||
|
UserLevel uint32 `json:"userLevel"`
|
||||||
|
}
|
||||||
|
var relayInbound xray.InboundConfig
|
||||||
|
for _, candidate := range cfg.InboundConfigs {
|
||||||
|
if candidate.Tag == ib.Tag {
|
||||||
|
relayInbound = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(relayInbound.Settings, &relaySettings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
relayPolicy := parsed.Policy.Levels[relaySettings.UserLevel]
|
||||||
|
if relaySettings.UserLevel == 0 || relaySettings.UserLevel == ^uint32(0) || relayPolicy == nil ||
|
||||||
|
relayPolicy.ConnectionIdle == nil || *relayPolicy.ConnectionIdle != 123 ||
|
||||||
|
relayPolicy.StatsUserUplink || relayPolicy.StatsUserDownlink || relayPolicy.StatsUserOnline {
|
||||||
|
t.Fatalf("relay policy lost session limits, collided with a custom level, or enabled duplicate stats: level=%d policy=%+v", relaySettings.UserLevel, relayPolicy)
|
||||||
|
}
|
||||||
|
if !parsed.Policy.Levels[0].StatsUserUplink || !parsed.Policy.Levels[^uint32(0)].StatsUserUplink {
|
||||||
|
t.Fatal("TUIC relay altered existing Xray user policy levels")
|
||||||
|
}
|
||||||
|
built, err := parsed.Build()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
x, err := core.New(built)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = x.Close() })
|
||||||
|
if err := x.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := publishTuicRelayReadiness(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counters := x.GetFeature(stats.ManagerType()).(stats.Manager)
|
||||||
|
instances, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil || len(instances) != 1 {
|
||||||
|
t.Fatalf("desired TUIC instances = %v, error %v", instances, err)
|
||||||
|
}
|
||||||
|
server, err := tuic.NewServer(instances[0], &tuic.SocksRelay{
|
||||||
|
Gate: tuic.ManagedRelayGate(tuic.SOCKSPortForInbound(ib.Id)), InboundID: ib.Id,
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", tuic.SOCKSPortForInbound(ib.Id)), Password: tuic.SocksPassword(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := server.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = server.Close() })
|
||||||
|
for _, credentials := range []struct{ email, password string }{
|
||||||
|
{"unknown@test", tuic.SocksPassword()}, {clients[0].Email, "wrong-password"},
|
||||||
|
} {
|
||||||
|
relay := &tuic.SocksRelay{Addr: fmt.Sprintf("127.0.0.1:%d", tuic.SOCKSPortForInbound(ib.Id)), Password: credentials.password}
|
||||||
|
conn, err := relay.DialTCP(context.Background(), credentials.email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697})
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
if err == nil || err.Error() != "tuic socks: auth rejected (code 255)" {
|
||||||
|
t.Fatalf("invalid relay credentials were not rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, route := range []string{"specific", "user", "fallback"} {
|
||||||
|
t.Run(route, func(t *testing.T) {
|
||||||
|
client := connectTuicRoutingClient(t, instances[0], clients[i])
|
||||||
|
for _, mode := range []string{"tcp", "native", "quic"} {
|
||||||
|
t.Run(mode, func(t *testing.T) { probeTuicRoute(t, client, mode, route+":"+tuicRoutingProbe) })
|
||||||
|
}
|
||||||
|
up, down := int64(3*len(tuicRoutingProbe)), int64(3*len(route+":"+tuicRoutingProbe))
|
||||||
|
var gotUp, gotDown int64
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for gotUp < up || gotDown < down {
|
||||||
|
for _, delta := range server.CollectClientTraffic() {
|
||||||
|
if delta.Email != clients[i].Email {
|
||||||
|
t.Fatalf("unexpected native traffic identity: %+v", delta)
|
||||||
|
}
|
||||||
|
gotUp += delta.Up
|
||||||
|
gotDown += delta.Down
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("native traffic = %d/%d, want %d/%d", gotUp, gotDown, up, down)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
if gotUp != up || gotDown != down {
|
||||||
|
t.Fatalf("native traffic = %d/%d, want %d/%d", gotUp, gotDown, up, down)
|
||||||
|
}
|
||||||
|
if _, _, err := (&InboundService{}).AddTraffic(nil, []*xray.ClientTraffic{{Email: clients[i].Email, Up: gotUp, Down: gotDown}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var billed xray.ClientTraffic
|
||||||
|
if err := database.GetDB().Where("email = ?", clients[i].Email).First(&billed).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if billed.Up != up || billed.Down != down {
|
||||||
|
t.Fatalf("persisted TUIC usage = %d/%d, want %d/%d", billed.Up, billed.Down, up, down)
|
||||||
|
}
|
||||||
|
for _, direction := range []string{"uplink", "downlink"} {
|
||||||
|
if counter := counters.GetCounter("user>>>" + clients[i].Email + ">>>traffic>>>" + direction); counter != nil {
|
||||||
|
t.Fatalf("TUIC relay created duplicate Xray %s counter: %d", direction, counter.Value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if online := counters.GetOnlineMap("user>>>" + clients[i].Email + ">>>online"); online != nil {
|
||||||
|
t.Fatal("TUIC relay exposed loopback addresses as user online IPs")
|
||||||
|
}
|
||||||
|
if counter := counters.GetCounter("inbound>>>" + ib.Tag + ">>>traffic>>>uplink"); counter == nil || counter.Value() < up {
|
||||||
|
t.Fatal("TUIC inbound totals were lost while disabling duplicate per-user counters")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, reset := range []string{"telegram", "scheduled"} {
|
||||||
|
t.Run("restore-"+reset, func(t *testing.T) {
|
||||||
|
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Update("enable", false).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server.UpdateUsers(nil)
|
||||||
|
var resetErr error
|
||||||
|
if reset == "telegram" {
|
||||||
|
resetErr = (&InboundService{}).ResetClientTrafficByEmail(clients[0].Email)
|
||||||
|
} else {
|
||||||
|
_, resetErr = (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id)
|
||||||
|
}
|
||||||
|
if resetErr != nil {
|
||||||
|
t.Fatal(resetErr)
|
||||||
|
}
|
||||||
|
renewed, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil || len(renewed) != 1 {
|
||||||
|
t.Fatalf("renewal missing: %+v %v", renewed, err)
|
||||||
|
}
|
||||||
|
server.UpdateUsers(renewed[0].Clients)
|
||||||
|
peer := connectTuicRoutingClient(t, renewed[0], clients[0])
|
||||||
|
for _, mode := range []string{"tcp", "native", "quic"} {
|
||||||
|
probeTuicRoute(t, peer, mode, "specific:"+tuicRoutingProbe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("replacement-window", func(t *testing.T) { verifyTuicReplacementWindow(t, cfg, apiPort, ib, server, clients) })
|
||||||
|
|
||||||
|
verifyTuicHotUserUpdates(t, cfg, apiPort, ib, server, clients, counters)
|
||||||
|
|
||||||
|
priorOrdinary := int64(0)
|
||||||
|
if counter := counters.GetCounter("user>>>" + clients[1].Email + ">>>traffic>>>uplink"); counter != nil {
|
||||||
|
priorOrdinary = counter.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
relay := &tuic.SocksRelay{Addr: fmt.Sprintf("127.0.0.1:%d", ordinaryPort), Password: "ordinary-password"}
|
||||||
|
conn, err := relay.DialTCP(context.Background(), clients[1].Email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
if _, err := conn.Write([]byte(tuicRoutingProbe)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := make([]byte, len("user:"+tuicRoutingProbe))
|
||||||
|
if _, err := io.ReadFull(conn, response); err != nil || string(response) != "user:"+tuicRoutingProbe {
|
||||||
|
t.Fatalf("ordinary SOCKS route = %q, error %v", response, err)
|
||||||
|
}
|
||||||
|
counter := counters.GetCounter("user>>>" + clients[1].Email + ">>>traffic>>>uplink")
|
||||||
|
if counter == nil || counter.Value()-priorOrdinary != int64(len(tuicRoutingProbe)) {
|
||||||
|
t.Fatal("ordinary Xray user traffic must still be counted independently of TUIC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTuicRouteMarker(t *testing.T, marker string) string {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
packet, err := net.ListenPacket("udp", listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = packet.Close() })
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
body := make([]byte, len(tuicRoutingProbe))
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(conn, body); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(append([]byte(marker+":"), body...)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
for {
|
||||||
|
n, addr, err := packet.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = packet.WriteTo(append([]byte(marker+":"), buf[:n]...), addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTuicHotUserUpdates(t *testing.T, initial *xray.Config, apiPort int, inbound *model.Inbound, server *tuic.Server, clients []model.Client, counters stats.Manager) {
|
||||||
|
t.Helper()
|
||||||
|
svc := &XrayService{}
|
||||||
|
if err := (&SettingService{}).SetRestartXrayOnClientDisable(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
api := &xray.XrayAPI{}
|
||||||
|
if err := api.Init(apiPort); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer api.Close()
|
||||||
|
ordinary := initial.InboundConfigs[len(initial.InboundConfigs)-1]
|
||||||
|
otherRelay := &tuic.SocksRelay{Addr: fmt.Sprintf("127.0.0.1:%d", ordinary.Port), Password: "ordinary-password"}
|
||||||
|
other, err := otherRelay.DialTCP(context.Background(), clients[1].Email, &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer other.Close()
|
||||||
|
assertOtherSurvives := func() {
|
||||||
|
t.Helper()
|
||||||
|
_ = other.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
if _, err := other.Write([]byte(tuicRoutingProbe)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
response := make([]byte, len("user:"+tuicRoutingProbe))
|
||||||
|
if _, err := io.ReadFull(other, response); err != nil || string(response) != "user:"+tuicRoutingProbe {
|
||||||
|
t.Fatalf("TUIC account edit interrupted an unrelated live connection: %q, %v", response, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertOtherSurvives()
|
||||||
|
instances, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil || len(instances) != 1 {
|
||||||
|
t.Fatalf("missing native instance: %+v, %v", instances, err)
|
||||||
|
}
|
||||||
|
persistent := connectTuicRoutingClient(t, instances[0], clients[1])
|
||||||
|
assertNativeSurvives := func() {
|
||||||
|
t.Helper()
|
||||||
|
for _, mode := range []string{"native", "quic"} {
|
||||||
|
probeTuicRoute(t, persistent, mode, "user:"+tuicRoutingProbe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNativeSurvives()
|
||||||
|
t.Run("hot-rollback", func(t *testing.T) {
|
||||||
|
var previous xray.InboundConfig
|
||||||
|
for _, candidate := range initial.InboundConfigs {
|
||||||
|
if candidate.TuicRelay {
|
||||||
|
previous = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid := previous
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal(previous.Settings, &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings["accounts"] = []relayAccount{{User: strings.Repeat("x", 256), Pass: "secret"}}
|
||||||
|
invalid.Settings = mustJSON(t, settings)
|
||||||
|
err := applyTuicRelayReplacements(api, []xray.InboundReplacement{{Previous: &previous, Next: &invalid}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "credentials exceed SOCKS5 bounds") {
|
||||||
|
t.Fatalf("invalid replacement was not refused: %v", err)
|
||||||
|
}
|
||||||
|
assertOtherSurvives()
|
||||||
|
assertNativeSurvives()
|
||||||
|
fresh := connectTuicRoutingClient(t, instances[0], clients[0])
|
||||||
|
for _, mode := range []string{"tcp", "native", "quic"} {
|
||||||
|
probeTuicRoute(t, fresh, mode, "specific:"+tuicRoutingProbe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
added := model.Client{Email: "добавлен@test", ID: uuid.NewString(), Password: "new-password", Enable: true}
|
||||||
|
before := initial
|
||||||
|
for _, step := range []string{"add", "delete", "rename", "empty"} {
|
||||||
|
t.Run("hot-"+step, func(t *testing.T) {
|
||||||
|
switch step {
|
||||||
|
case "add":
|
||||||
|
clients = append(clients, added)
|
||||||
|
case "delete":
|
||||||
|
clients = clients[1:]
|
||||||
|
case "rename":
|
||||||
|
added.Email = "переименован@test"
|
||||||
|
clients[len(clients)-1] = added
|
||||||
|
case "empty":
|
||||||
|
clients = nil
|
||||||
|
}
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings["clients"] = clients
|
||||||
|
if err := database.GetDB().Model(inbound).Update("settings", string(mustJSON(t, settings))).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := svc.GetXrayConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after.InboundConfigs = append(after.InboundConfigs, ordinary)
|
||||||
|
diff, hot := xray.ComputeHotDiff(before, after)
|
||||||
|
if !hot || svc.restartToDropClients(diff) || applyTuicRelayReplacements(api, diff.TuicRelayChanges) != nil || !applyXrayHotDiff(api, diff) {
|
||||||
|
t.Fatalf("account update requires a core restart: hot=%t diff=%+v", hot, diff)
|
||||||
|
}
|
||||||
|
native, err := (&InboundService{}).DesiredTuicInstances()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(native) > 0 {
|
||||||
|
server.UpdateUsers(native[0].Clients)
|
||||||
|
peer := connectTuicRoutingClient(t, native[0], added)
|
||||||
|
for _, mode := range []string{"tcp", "native", "quic"} {
|
||||||
|
probeTuicRoute(t, peer, mode, "fallback:"+tuicRoutingProbe)
|
||||||
|
}
|
||||||
|
if counter := counters.GetCounter("user>>>" + added.Email + ">>>traffic>>>uplink"); counter != nil {
|
||||||
|
t.Fatalf("hot-added user lost relay policy and was billed twice: %d", counter.Value())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
server.UpdateUsers(nil)
|
||||||
|
}
|
||||||
|
if step != "empty" {
|
||||||
|
assertNativeSurvives()
|
||||||
|
}
|
||||||
|
assertOtherSurvives()
|
||||||
|
before = after
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectTuicRoutingClient(t *testing.T, inst tuic.Instance, user model.Client) *quic.Conn {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client, err := quic.DialAddr(ctx, inst.BindTo(), &tls.Config{InsecureSkipVerify: true, NextProtos: inst.ALPN}, &quic.Config{EnableDatagrams: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = client.CloseWithError(0, "complete") })
|
||||||
|
id := uuid.MustParse(user.ID)
|
||||||
|
tlsState := client.ConnectionState().TLS
|
||||||
|
token, err := tlsState.ExportKeyingMaterial(string(id[:]), []byte(user.Password), 32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream, err := client.OpenUniStreamSync(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := stream.Write(append(append([]byte{tuic.ProtocolVersion, tuic.CmdAuthenticate}, id[:]...), token...)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = stream.Close()
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeTuicRoute(t *testing.T, client *quic.Conn, mode, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := exchangeTuicRoute(client, mode, want); err != nil {
|
||||||
|
t.Fatalf("%s relay request failed: %v", mode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeTuicRoute(client *quic.Conn, mode, want string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
target := &tuic.Address{Type: tuic.AddrTypeIPv4, IP: net.IPv4(192, 0, 2, 1), Port: 6697}
|
||||||
|
var frame bytes.Buffer
|
||||||
|
var reader io.Reader
|
||||||
|
switch mode {
|
||||||
|
case "tcp":
|
||||||
|
stream, err := client.OpenStreamSync(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
_ = stream.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
frame.Write([]byte{tuic.ProtocolVersion, tuic.CmdConnect})
|
||||||
|
if err := tuic.WriteAddress(&frame, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frame.WriteString(tuicRoutingProbe)
|
||||||
|
if _, err := stream.Write(frame.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader = stream
|
||||||
|
case "native", "quic":
|
||||||
|
association := uint16(1)
|
||||||
|
if mode == "quic" {
|
||||||
|
association = 2
|
||||||
|
}
|
||||||
|
if err := tuic.WritePacket(&frame, association, 1, 1, 0, target, []byte(tuicRoutingProbe)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mode == "native" {
|
||||||
|
if err := client.SendDatagram(frame.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response, err := client.ReceiveDatagram(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(response)
|
||||||
|
} else {
|
||||||
|
stream, err := client.OpenUniStreamSync(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := stream.Write(frame.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = stream.Close()
|
||||||
|
response, err := client.AcceptUniStream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = response.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
reader = response
|
||||||
|
}
|
||||||
|
_, cmd, err := tuic.ReadCommand(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read UDP response command: %w", err)
|
||||||
|
}
|
||||||
|
if cmd != tuic.CmdPacket {
|
||||||
|
return fmt.Errorf("UDP response command = %d, want Packet", cmd)
|
||||||
|
}
|
||||||
|
hdr, err := tuic.ReadPacketHeader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read UDP response header: %w", err)
|
||||||
|
}
|
||||||
|
if hdr.AssocID != association || hdr.Size != uint16(len(want)) {
|
||||||
|
return fmt.Errorf("UDP response header = %+v, want association %d and size %d", hdr, association, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response := make([]byte, len(want))
|
||||||
|
if _, err := io.ReadFull(reader, response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(response) != want {
|
||||||
|
return fmt.Errorf("%s route returned %q, want %q", mode, response, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+219
-58
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -24,10 +25,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
|
isNeedXrayReconcile atomic.Bool
|
||||||
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
|
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
|
||||||
xrayState xrayLifecycle
|
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
|
||||||
|
xrayState xrayLifecycle
|
||||||
)
|
)
|
||||||
|
|
||||||
type xrayLifecycle struct {
|
type xrayLifecycle struct {
|
||||||
@@ -448,7 +450,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
// whatever rules the admin adds through the stock Routing page, exactly
|
// whatever rules the admin adds through the stock Routing page, exactly
|
||||||
// like routing any other protocol.
|
// like routing any other protocol.
|
||||||
injectAmneziawgnetSocks(xrayConfig, inbounds)
|
injectAmneziawgnetSocks(xrayConfig, inbounds)
|
||||||
injectTuicSocks(xrayConfig, inbounds)
|
tuicInstances := configuredTuicInstances(inbounds)
|
||||||
|
if err := injectTuicRelay(xrayConfig, tuicInstances); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Restores each opted-in peer's own distinct public IPv6 source identity
|
// 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
|
// for its outbound connections — a peer that has an IPv6 address in its
|
||||||
@@ -818,38 +823,121 @@ func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// Keep sniffed domains available for routing without replacing the requested destination.
|
||||||
tuicEgressSocksSettings = `{"auth":"noauth","udp":true}`
|
const tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"],"routeOnly":true}`
|
||||||
// TUIC clients can connect to an IP while TLS carries a different or unresolvable SNI.
|
|
||||||
// Keep sniffed domains available for routing without replacing the requested destination.
|
|
||||||
tuicEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"],"routeOnly":true}`
|
|
||||||
)
|
|
||||||
|
|
||||||
func injectTuicSocks(cfg *xray.Config, inbounds []*model.Inbound) {
|
func injectTuicRelay(cfg *xray.Config, instances []tuic.Instance) error {
|
||||||
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
|
existingTags := map[string]struct{}{"api": {}}
|
||||||
|
var api struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
if len(cfg.API) > 0 {
|
||||||
|
if err := json.Unmarshal(cfg.API, &api); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if api.Tag != "" {
|
||||||
|
existingTags[api.Tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := range cfg.InboundConfigs {
|
for i := range cfg.InboundConfigs {
|
||||||
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
|
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
|
||||||
}
|
}
|
||||||
|
var userLevel uint32
|
||||||
for _, inbound := range inbounds {
|
policyReady := false
|
||||||
if inbound.Protocol != model.TUIC || !inbound.Enable || inbound.NodeID != nil {
|
for _, inst := range instances {
|
||||||
|
if _, taken := existingTags[inst.Tag]; taken {
|
||||||
|
logger.Warningf("tuic: skipping inbound %d: relay tag conflicts with another inbound", inst.Id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, taken := existingTags[inbound.Tag]; taken {
|
emails := make([]string, 0, len(inst.Clients))
|
||||||
logger.Warning("tuic socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
|
for _, client := range inst.Clients {
|
||||||
continue
|
if client.Email != "" {
|
||||||
|
emails = append(emails, client.Email)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !policyReady {
|
||||||
existingTags[inbound.Tag] = struct{}{}
|
var err error
|
||||||
|
userLevel, err = tuicRelayPolicyLevel(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tuic relay: policy: %w", err)
|
||||||
|
}
|
||||||
|
policyReady = true
|
||||||
|
}
|
||||||
|
slices.Sort(emails)
|
||||||
|
emails = slices.Compact(emails)
|
||||||
|
settings, err := tuic.SocksInboundSettings(emails, tuic.SocksPassword(), userLevel)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tuic relay: inbound %d: %w", inst.Id, err)
|
||||||
|
}
|
||||||
|
existingTags[inst.Tag] = struct{}{}
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
|
||||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||||
Port: tuic.SOCKSPortForInbound(inbound.Id),
|
Port: tuic.SOCKSPortForInbound(inst.Id),
|
||||||
Protocol: "socks",
|
Protocol: "socks",
|
||||||
Settings: json_util.RawMessage(tuicEgressSocksSettings),
|
TuicRelay: true,
|
||||||
Sniffing: json_util.RawMessage(tuicEgressSniffingSettings),
|
TuicInboundID: inst.Id,
|
||||||
Tag: inbound.Tag,
|
Settings: json_util.RawMessage(settings),
|
||||||
|
Sniffing: json_util.RawMessage(tuicEgressSniffingSettings),
|
||||||
|
Tag: inst.Tag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay authentication carries routing identity; native TUIC owns client accounting and source IPs.
|
||||||
|
// Copy session limits from level 0, but disable Xray's duplicate traffic and loopback online stats.
|
||||||
|
func tuicRelayPolicyLevel(cfg *xray.Config) (uint32, error) {
|
||||||
|
var policy map[string]json.RawMessage
|
||||||
|
if len(cfg.Policy) > 0 {
|
||||||
|
if err := json.Unmarshal(cfg.Policy, &policy); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if policy == nil {
|
||||||
|
policy = make(map[string]json.RawMessage)
|
||||||
|
}
|
||||||
|
var levels map[uint32]json.RawMessage
|
||||||
|
if raw, ok := policy["levels"]; ok {
|
||||||
|
if err := json.Unmarshal(raw, &levels); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if levels == nil {
|
||||||
|
levels = make(map[uint32]json.RawMessage)
|
||||||
|
}
|
||||||
|
var relayPolicy map[string]json.RawMessage
|
||||||
|
if raw, ok := levels[0]; ok {
|
||||||
|
if err := json.Unmarshal(raw, &relayPolicy); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if relayPolicy == nil {
|
||||||
|
relayPolicy = make(map[string]json.RawMessage)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"statsUserUplink", "statsUserDownlink", "statsUserOnline"} {
|
||||||
|
relayPolicy[key] = json.RawMessage("false")
|
||||||
|
}
|
||||||
|
level := ^uint32(0)
|
||||||
|
for {
|
||||||
|
if _, exists := levels[level]; !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if level == 0 {
|
||||||
|
return 0, errors.New("no free user policy level")
|
||||||
|
}
|
||||||
|
level--
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(relayPolicy)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
levels[level] = raw
|
||||||
|
policy["levels"], err = json.Marshal(levels)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
cfg.Policy, err = json.Marshal(policy)
|
||||||
|
return level, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
|
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
|
||||||
@@ -1404,7 +1492,7 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
|||||||
configUnchanged := process.GetConfig().Equals(xrayConfig)
|
configUnchanged := process.GetConfig().Equals(xrayConfig)
|
||||||
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
|
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
|
||||||
logger.Debug("It does not need to restart Xray")
|
logger.Debug("It does not need to restart Xray")
|
||||||
return nil
|
return repairUnavailableTuicRelays(xrayConfig, process.GetAPIPort())
|
||||||
}
|
}
|
||||||
// A config the core cannot bind never replaces one that works: its failed
|
// A config the core cannot bind never replaces one that works: its failed
|
||||||
// start exits the core, and the watchdog would then loop on it forever.
|
// start exits the core, and the watchdog would then loop on it forever.
|
||||||
@@ -1418,10 +1506,18 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
|||||||
xrayState.holdBack(refused)
|
xrayState.holdBack(refused)
|
||||||
return fmt.Errorf("xray %s", refused)
|
return fmt.Errorf("xray %s", refused)
|
||||||
}
|
}
|
||||||
if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
|
if !isForce && !configUnchanged {
|
||||||
logger.Info("Xray config changes applied through the core API, no restart needed")
|
applied, err := s.tryHotApply(process, xrayConfig)
|
||||||
return nil
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
logger.Info("Xray config changes applied through the core API, no restart needed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
invalidateTuicRelayReadiness(process.GetConfig())
|
||||||
|
invalidateTuicRelayReadiness(xrayConfig)
|
||||||
_ = process.Stop()
|
_ = process.Stop()
|
||||||
} else if conflicts := bindConflicts(xrayConfig, nil); len(conflicts) > 0 {
|
} else if conflicts := bindConflicts(xrayConfig, nil); len(conflicts) > 0 {
|
||||||
// Nothing is running to protect and the core is the authority on what it
|
// Nothing is running to protect and the core is the authority on what it
|
||||||
@@ -1429,6 +1525,7 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
|||||||
logger.Warning("xray config may not start:", conflicts[0].String())
|
logger.Warning("xray config may not start:", conflicts[0].String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateTuicRelayReadiness(xrayConfig)
|
||||||
process = xray.NewProcess(xrayConfig)
|
process = xray.NewProcess(xrayConfig)
|
||||||
xrayState.replace(process)
|
xrayState.replace(process)
|
||||||
s.xrayAPI.StatsLastValues = nil
|
s.xrayAPI.StatsLastValues = nil
|
||||||
@@ -1437,6 +1534,10 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := publishTuicRelayReadiness(xrayConfig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,44 +1555,101 @@ func (s *XrayService) restartToDropClients(diff *xray.HotDiff) bool {
|
|||||||
return restart
|
return restart
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryHotApply attempts to reconcile the running Xray instance with newCfg
|
// tryHotApply reconciles the running core with newCfg over gRPC; false means restart, while a TUIC
|
||||||
// through the core gRPC API (HandlerService for inbounds/outbounds,
|
// relay error defers that update instead of restarting unrelated connections. Hold the package lock.
|
||||||
// RoutingService for rules/balancers). It returns true when the running
|
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) (applied bool, applyErr error) {
|
||||||
// instance now matches newCfg; on any failure it returns false and the
|
defer func() {
|
||||||
// caller falls back to a full process restart, which cleans up whatever was
|
if applyErr != nil {
|
||||||
// partially applied. Callers must hold the package-level lock.
|
s.SetToNeedReconcile()
|
||||||
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bool {
|
}
|
||||||
|
}()
|
||||||
oldCfg := process.GetConfig()
|
oldCfg := process.GetConfig()
|
||||||
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
|
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
|
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
if diff.Empty() {
|
if diff.Empty() {
|
||||||
|
if err := repairUnavailableTuicRelays(newCfg, process.GetAPIPort()); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
process.SetConfig(newCfg)
|
process.SetConfig(newCfg)
|
||||||
persistHotConfig(process)
|
persistHotConfig(process)
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
// The core's RemoveUser drops the credential only, so a disabled or deleted
|
// The core's RemoveUser drops the credential only, so a disabled or deleted
|
||||||
// client needs the restart this setting asks for.
|
// client needs the restart this setting asks for.
|
||||||
if s.restartToDropClients(diff) {
|
if s.restartToDropClients(diff) {
|
||||||
logger.Info("hot apply: clients left the config, restarting to drop their live sessions")
|
logger.Info("hot apply: clients left the config, restarting to drop their live sessions")
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tuicRequiresOrdinaryRemoval(oldCfg, diff.TuicRelayChanges) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
apiPort := process.GetAPIPort()
|
apiPort := process.GetAPIPort()
|
||||||
if apiPort <= 0 {
|
if apiPort <= 0 {
|
||||||
return false
|
if len(diff.TuicRelayChanges) > 0 {
|
||||||
|
return false, errors.New("TUIC relay update requires the Xray HandlerService API")
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
// A dedicated client: s.xrayAPI may be in use by traffic polling on other
|
// A dedicated client: s.xrayAPI may be in use by traffic polling on other
|
||||||
// service instances and is reset around restarts.
|
// service instances and is reset around restarts.
|
||||||
hotAPI := xray.XrayAPI{}
|
hotAPI := xray.XrayAPI{}
|
||||||
if err := hotAPI.Init(apiPort); err != nil {
|
if err := hotAPI.Init(apiPort); err != nil {
|
||||||
logger.Debug("hot apply: failed to init xray api:", err)
|
logger.Debug("hot apply: failed to init xray api:", err)
|
||||||
return false
|
if len(diff.TuicRelayChanges) > 0 {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
defer hotAPI.Close()
|
defer hotAPI.Close()
|
||||||
|
|
||||||
|
staged, relayErr := reconcileTuicHotConfig(&hotAPI, oldCfg, newCfg, diff.TuicRelayChanges)
|
||||||
|
defer staged.finish()
|
||||||
|
effective := staged.effective
|
||||||
|
intermediate := *oldCfg
|
||||||
|
intermediate.InboundConfigs = nil
|
||||||
|
for _, inbound := range oldCfg.InboundConfigs {
|
||||||
|
if !inbound.TuicRelay {
|
||||||
|
intermediate.InboundConfigs = append(intermediate.InboundConfigs, inbound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, inbound := range effective.InboundConfigs {
|
||||||
|
if inbound.TuicRelay {
|
||||||
|
intermediate.InboundConfigs = append(intermediate.InboundConfigs, inbound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordinary, hot := xray.ComputeHotDiff(&intermediate, effective)
|
||||||
|
if !hot || len(ordinary.TuicRelayChanges) > 0 {
|
||||||
|
relayErr = errors.Join(relayErr, staged.rollback(&hotAPI))
|
||||||
|
intermediate.InboundConfigs = oldCfg.InboundConfigs
|
||||||
|
process.SetConfig(&intermediate)
|
||||||
|
persistHotConfig(process)
|
||||||
|
|
||||||
|
return false, errors.Join(relayErr, errors.New("independent Xray changes could not be planned after TUIC reconciliation"))
|
||||||
|
}
|
||||||
|
if !applyXrayHotDiff(&hotAPI, ordinary) {
|
||||||
|
relayErr = errors.Join(relayErr, staged.rollback(&hotAPI))
|
||||||
|
intermediate.InboundConfigs = oldCfg.InboundConfigs
|
||||||
|
process.SetConfig(&intermediate)
|
||||||
|
persistHotConfig(process)
|
||||||
|
if relayErr != nil {
|
||||||
|
return false, relayErr
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
balancerOverrides.reapply(process, &hotAPI, effective)
|
||||||
|
process.SetConfig(effective)
|
||||||
|
persistHotConfig(process)
|
||||||
|
if relayErr != nil {
|
||||||
|
return false, relayErr
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyXrayHotDiff(hotAPI *xray.XrayAPI, diff *xray.HotDiff) bool {
|
||||||
// Removals first so changed handlers and port swaps never collide with
|
// Removals first so changed handlers and port swaps never collide with
|
||||||
// the additions that follow.
|
// the additions that follow.
|
||||||
for _, u := range diff.RemovedUsers {
|
for _, u := range diff.RemovedUsers {
|
||||||
@@ -1513,19 +1671,19 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, ob := range diff.AddedOutbounds {
|
for _, ob := range diff.AddedOutbounds {
|
||||||
if err := addOutboundReconciling(&hotAPI, ob); err != nil {
|
if err := addOutboundReconciling(hotAPI, ob); err != nil {
|
||||||
logger.Info("hot apply: add outbound failed:", err)
|
logger.Info("hot apply: add outbound failed:", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, ib := range diff.AddedInbounds {
|
for _, ib := range diff.AddedInbounds {
|
||||||
if err := addInboundReconciling(&hotAPI, ib); err != nil {
|
if err := addInboundReconciling(hotAPI, ib); err != nil {
|
||||||
logger.Info("hot apply: add inbound failed:", err)
|
logger.Info("hot apply: add inbound failed:", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, u := range diff.AddedUsers {
|
for _, u := range diff.AddedUsers {
|
||||||
if err := addUserReconciling(&hotAPI, u); err != nil {
|
if err := addUserReconciling(hotAPI, u); err != nil {
|
||||||
logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
|
logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1536,11 +1694,6 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A routing reload rebuilds every balancer, and a removed outbound strands an override.
|
|
||||||
balancerOverrides.reapply(process, &hotAPI, newCfg)
|
|
||||||
|
|
||||||
process.SetConfig(newCfg)
|
|
||||||
persistHotConfig(process)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,6 +1774,9 @@ func (s *XrayService) SetToNeedRestart() {
|
|||||||
isNeedXrayRestart.Store(true)
|
isNeedXrayRestart.Store(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetToNeedReconcile schedules config reconciliation without forcing an unchanged core to restart.
|
||||||
|
func (s *XrayService) SetToNeedReconcile() { isNeedXrayReconcile.Store(true) }
|
||||||
|
|
||||||
// GetXrayAPIPort returns the port the local xray process is listening on
|
// GetXrayAPIPort returns the port the local xray process is listening on
|
||||||
// for its gRPC HandlerService, or 0 when xray isn't currently running.
|
// for its gRPC HandlerService, or 0 when xray isn't currently running.
|
||||||
// Exposed for the runtime package's LocalRuntime adapter without a
|
// Exposed for the runtime package's LocalRuntime adapter without a
|
||||||
@@ -1638,17 +1794,22 @@ func (s *XrayService) IsNeedRestartAndSetFalse() bool {
|
|||||||
return isNeedXrayRestart.CompareAndSwap(true, false)
|
return isNeedXrayRestart.CompareAndSwap(true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyPendingRestart consumes the need-restart flag and restarts Xray. If the
|
// ApplyPendingRestart re-arms the original flags when applying fails, so the next tick retries; a
|
||||||
// restart fails (for example GetXrayConfig hits a transient DB error and leaves
|
// reconcile retry never becomes a restart, which would disconnect unrelated clients.
|
||||||
// the old process running), it re-arms the flag so the next tick retries instead
|
|
||||||
// of silently dropping the pending config change.
|
|
||||||
func (s *XrayService) ApplyPendingRestart() {
|
func (s *XrayService) ApplyPendingRestart() {
|
||||||
if !s.IsNeedRestartAndSetFalse() {
|
reconcile := isNeedXrayReconcile.Swap(false)
|
||||||
|
restart := s.IsNeedRestartAndSetFalse()
|
||||||
|
if !restart && !reconcile {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.RestartXray(false); err != nil {
|
if err := s.RestartXray(false); err != nil {
|
||||||
logger.Error("restart xray failed:", err)
|
logger.Error("reconcile xray failed:", err)
|
||||||
s.SetToNeedRestart()
|
if restart {
|
||||||
|
s.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
if reconcile {
|
||||||
|
s.SetToNeedReconcile()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func saveDuringTest(t *testing.T, process *xray.Process, cfg *xray.Config) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
if !(&XrayService{}).tryHotApply(process, cfg) {
|
if applied, err := (&XrayService{}).tryHotApply(process, cfg); err != nil || !applied {
|
||||||
t.Fatal("tryHotApply = false, want the change applied live")
|
t.Fatal("tryHotApply = false, want the change applied live")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ func TestBalancerOverrideSurvivesRoutingHotApply(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort)
|
newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort)
|
||||||
if !svc.tryHotApply(process, newCfg) {
|
if applied, err := svc.tryHotApply(process, newCfg); err != nil || !applied {
|
||||||
t.Fatal("tryHotApply = false, want the routing change applied live")
|
t.Fatal("tryHotApply = false, want the routing change applied live")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ func TestBalancerOverrideClearedWhenTargetOutboundRemoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newCfg := balancerTestConfig{outbounds: []string{"direct", "blocked", "proxy-a"}}.build(t, apiPort)
|
newCfg := balancerTestConfig{outbounds: []string{"direct", "blocked", "proxy-a"}}.build(t, apiPort)
|
||||||
if !svc.tryHotApply(process, newCfg) {
|
if applied, err := svc.tryHotApply(process, newCfg); err != nil || !applied {
|
||||||
t.Fatal("tryHotApply = false, want the outbound removal applied live")
|
t.Fatal("tryHotApply = false, want the outbound removal applied live")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ func TestBalancerOverrideNotCarriedAcrossRestart(t *testing.T) {
|
|||||||
|
|
||||||
restarted, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds})
|
restarted, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds})
|
||||||
newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort)
|
newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort)
|
||||||
if !(&XrayService{}).tryHotApply(restarted, newCfg) {
|
if applied, err := (&XrayService{}).tryHotApply(restarted, newCfg); err != nil || !applied {
|
||||||
t.Fatal("tryHotApply = false, want the routing change applied live")
|
t.Fatal("tryHotApply = false, want the routing change applied live")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,33 @@ func TestTryHotApplyWritesConfigFile(t *testing.T) {
|
|||||||
routing := json_util.RawMessage(`{"rules":[]}`)
|
routing := json_util.RawMessage(`{"rules":[]}`)
|
||||||
process := xray.NewTestProcess(&xray.Config{RouterConfig: routing}, path)
|
process := xray.NewTestProcess(&xray.Config{RouterConfig: routing}, path)
|
||||||
|
|
||||||
if !(&XrayService{}).tryHotApply(process, &xray.Config{RouterConfig: routing}) {
|
if applied, err := (&XrayService{}).tryHotApply(process, &xray.Config{RouterConfig: routing}); err != nil || !applied {
|
||||||
t.Fatal("tryHotApply = false, want true for an unchanged config")
|
t.Fatal("tryHotApply = false, want true for an unchanged config")
|
||||||
}
|
}
|
||||||
if data, err := os.ReadFile(path); err != nil || len(data) == 0 {
|
if data, err := os.ReadFile(path); err != nil || len(data) == 0 {
|
||||||
t.Fatalf("config.json not written after hot apply (err %v)", err)
|
t.Fatalf("config.json not written after hot apply (err %v)", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTuicChangeWithoutAPIDoesNotFallBackToCoreRestart(t *testing.T) {
|
||||||
|
wasPending := isNeedXrayReconcile.Swap(false)
|
||||||
|
t.Cleanup(func() { isNeedXrayReconcile.Store(wasPending) })
|
||||||
|
previous := &xray.Config{InboundConfigs: []xray.InboundConfig{{
|
||||||
|
Tag: "tuic-test", Listen: json_util.RawMessage(`"127.0.0.1"`), Port: 64001,
|
||||||
|
Protocol: "socks", TuicRelay: true,
|
||||||
|
Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"old@test","pass":"secret"}]}`),
|
||||||
|
}}}
|
||||||
|
next := &xray.Config{InboundConfigs: append([]xray.InboundConfig(nil), previous.InboundConfigs...)}
|
||||||
|
next.InboundConfigs[0].Settings = json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"new@test","pass":"secret"}]}`)
|
||||||
|
process := xray.NewTestProcess(previous, filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
applied, err := (&XrayService{}).tryHotApply(process, next)
|
||||||
|
if applied || err == nil || err.Error() != "TUIC relay update requires the Xray HandlerService API" {
|
||||||
|
t.Fatalf("missing API must defer TUIC changes, not fall back to restarting the core: applied=%t err=%v", applied, err)
|
||||||
|
}
|
||||||
|
if !process.GetConfig().Equals(previous) {
|
||||||
|
t.Fatal("failed relay update advanced the running config snapshot")
|
||||||
|
}
|
||||||
|
if !isNeedXrayReconcile.Load() {
|
||||||
|
t.Fatal("direct reconciliation failure did not schedule a retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -555,8 +555,9 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
|||||||
// The closures bridge into XrayService (which owns the running xray
|
// The closures bridge into XrayService (which owns the running xray
|
||||||
// process state) without forcing the runtime package to import service.
|
// process state) without forcing the runtime package to import service.
|
||||||
mgr := runtime.NewManager(runtime.LocalDeps{
|
mgr := runtime.NewManager(runtime.LocalDeps{
|
||||||
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
|
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
|
||||||
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
|
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
|
||||||
|
RequestReconcile: func() { s.xrayService.SetToNeedReconcile() },
|
||||||
})
|
})
|
||||||
runtime.SetManager(mgr)
|
runtime.SetManager(mgr)
|
||||||
mgr.SetNodeEgressResolver(&s.settingService)
|
mgr.SetNodeEgressResolver(&s.settingService)
|
||||||
|
|||||||
+23
-10
@@ -14,6 +14,7 @@ import (
|
|||||||
// process. It only covers the sections Xray can reload at runtime: inbounds,
|
// process. It only covers the sections Xray can reload at runtime: inbounds,
|
||||||
// outbounds and routing rules/balancers.
|
// outbounds and routing rules/balancers.
|
||||||
type HotDiff struct {
|
type HotDiff struct {
|
||||||
|
TuicRelayChanges []InboundReplacement
|
||||||
RemovedInboundTags []string
|
RemovedInboundTags []string
|
||||||
AddedInbounds [][]byte
|
AddedInbounds [][]byte
|
||||||
RemovedUsers []UserOp
|
RemovedUsers []UserOp
|
||||||
@@ -26,6 +27,12 @@ type HotDiff struct {
|
|||||||
RoutingConfig []byte // full new routing section; nil when unchanged
|
RoutingConfig []byte // full new routing section; nil when unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboundReplacement is verified independently of ordinary hot changes.
|
||||||
|
type InboundReplacement struct {
|
||||||
|
Previous *InboundConfig
|
||||||
|
Next *InboundConfig
|
||||||
|
}
|
||||||
|
|
||||||
// UserOp is a per-user AlterInbound operation; User is nil for removals.
|
// UserOp is a per-user AlterInbound operation; User is nil for removals.
|
||||||
type UserOp struct {
|
type UserOp struct {
|
||||||
Tag string
|
Tag string
|
||||||
@@ -57,7 +64,7 @@ func (d *HotDiff) DropsUsers() bool {
|
|||||||
|
|
||||||
// Empty reports whether the diff contains no operations.
|
// Empty reports whether the diff contains no operations.
|
||||||
func (d *HotDiff) Empty() bool {
|
func (d *HotDiff) Empty() bool {
|
||||||
return len(d.RemovedInboundTags) == 0 &&
|
return len(d.TuicRelayChanges) == 0 && len(d.RemovedInboundTags) == 0 &&
|
||||||
len(d.AddedInbounds) == 0 &&
|
len(d.AddedInbounds) == 0 &&
|
||||||
len(d.RemovedUsers) == 0 &&
|
len(d.RemovedUsers) == 0 &&
|
||||||
len(d.AddedUsers) == 0 &&
|
len(d.AddedUsers) == 0 &&
|
||||||
@@ -146,11 +153,15 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
|||||||
if oldIb.Tag == apiTag || oldIb.Tag == "api" {
|
if oldIb.Tag == apiTag || oldIb.Tag == "api" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if oldIb.TuicRelay && (!exists || newIb.TuicRelay) {
|
||||||
|
diff.TuicRelayChanges = append(diff.TuicRelayChanges, InboundReplacement{Previous: oldIb, Next: newIb})
|
||||||
|
continue
|
||||||
|
}
|
||||||
if exists && (inboundHasReverseClient(oldIb) || inboundHasReverseClient(newIb)) {
|
if exists && (inboundHasReverseClient(oldIb) || inboundHasReverseClient(newIb)) {
|
||||||
logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
|
logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if exists {
|
if exists && !oldIb.TuicRelay {
|
||||||
diff.DroppedClients = append(diff.DroppedClients, droppedClients(oldIb, newIb)...)
|
diff.DroppedClients = append(diff.DroppedClients, droppedClients(oldIb, newIb)...)
|
||||||
}
|
}
|
||||||
if exists && diffInboundUsers(oldIb, newIb, diff) {
|
if exists && diffInboundUsers(oldIb, newIb, diff) {
|
||||||
@@ -165,7 +176,7 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
|
if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
|
||||||
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
|
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. TUIC or AmneziaWG relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
|
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
|
||||||
@@ -185,12 +196,16 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
|||||||
if newIb.Tag == apiTag || newIb.Tag == "api" {
|
if newIb.Tag == apiTag || newIb.Tag == "api" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if newIb.TuicRelay {
|
||||||
|
diff.TuicRelayChanges = append(diff.TuicRelayChanges, InboundReplacement{Next: newIb})
|
||||||
|
continue
|
||||||
|
}
|
||||||
if inboundUsesTproxy(newIb) {
|
if inboundUsesTproxy(newIb) {
|
||||||
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
|
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if inboundUsesSocksAccounts(newIb) {
|
if inboundUsesSocksAccounts(newIb) {
|
||||||
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
|
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. TUIC or AmneziaWG relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(newIb)
|
raw, err := json.Marshal(newIb)
|
||||||
@@ -384,12 +399,10 @@ func inboundUsesTproxy(ib *InboundConfig) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inboundUsesSocksAccounts reports whether an inbound is a password-auth
|
// inboundUsesSocksAccounts reports whether an inbound is a password-auth
|
||||||
// SOCKS5 inbound with one or more named accounts -- the shape
|
// SOCKS5 inbound. TUIC and AmneziaWG generate this shape through
|
||||||
// internal/amneziawgnet's per-inbound relay (internal/web/service/xray.go's
|
// injectTuicRelay and injectAmneziawgnetSocks in internal/web/service/xray.go.
|
||||||
// injectAmneziawgnetSocks) is the only generator of in this fork; every
|
// The panel, per-node and MTProto egress bridges use "noauth" without
|
||||||
// other SOCKS5 bridge this fork builds (panel egress, per-node egress,
|
// per-account identity, so this check does not affect those bridges.
|
||||||
// mtproto egress) uses "noauth" with no per-account identity at all, so this
|
|
||||||
// check can't accidentally rope in one of those lower-churn bridges.
|
|
||||||
//
|
//
|
||||||
// Real production incident, not a theoretical concern: a single client
|
// Real production incident, not a theoretical concern: a single client
|
||||||
// edit under an AmneziaWG inbound left this inbound's settings unchanged in
|
// edit under an AmneziaWG inbound left this inbound's settings unchanged in
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
// InboundConfig represents an Xray inbound configuration.
|
// InboundConfig represents an Xray inbound configuration.
|
||||||
// It defines how Xray accepts incoming connections including protocol, port, and settings.
|
// It defines how Xray accepts incoming connections including protocol, port, and settings.
|
||||||
type InboundConfig struct {
|
type InboundConfig struct {
|
||||||
|
// TuicRelay owns client revocation outside Xray; its user edits must not restart the core.
|
||||||
|
TuicRelay bool `json:"-"`
|
||||||
|
TuicInboundID int `json:"-"`
|
||||||
Listen json_util.RawMessage `json:"listen"` // listen cannot be an empty string
|
Listen json_util.RawMessage `json:"listen"` // listen cannot be an empty string
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ func newProcess(config *Config) *process {
|
|||||||
func newTestProcess(config *Config, configPath string) *process {
|
func newTestProcess(config *Config, configPath string) *process {
|
||||||
p := newProcess(config)
|
p := newProcess(config)
|
||||||
p.configPath = configPath
|
p.configPath = configPath
|
||||||
|
if config != nil {
|
||||||
|
p.refreshAPIPort()
|
||||||
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user