Files
3x-ui/internal/web/service/client_reverse_readd_test.go
T
BlindMaster24 cfa8350d10 fix(clients): keep a vless reverse client's handler across a re-add (#6558)
* fix(clients): keep a vless reverse client's handler across a re-add

RemoveUser also drops the client's reverse outbound handler, and the account
every live remove/re-add path rebuilt carried no reverse at all: buildUserAccount
read id/flow/testseed/testpre and nothing else. Editing, bulk re-enabling, quota
renewal and adding a client to an existing inbound therefore left a reverse
client able to connect but not to open its tunnel until Xray restarted, with
nothing logged. A traffic reset is the route operators hit most, since a
depleted client is removed and re-added on every renewal.

buildUserAccount now carries the tag (it accepts either the settings JSON object
or a typed client value), and the five account maps those paths build include
the client's reverse. Core chain, read from the pinned xray-core:
AddUserOperation -> User.ToMemoryUser -> vless.Account.AsAccount copies Reverse
(proxy/vless/account.go:24), and GetReverse rebuilds the handler from the stored
account's tag (proxy/vless/inbound/inbound.go:193-205).

Each path has a test that fails without its fix; the account-level test fails on
both input shapes.

* refactor(clients): drop an account map helper nothing calls

Local.AddClient and Local.UpdateUser are only reachable through runtime.Runtime,
and all four call sites of those two methods sit in a node branch, where the
runtime is a *Remote -- Remote.AddUser ignores the map and pushes the inbound
snapshot instead. So the extraction and its test covered a path no deployment
takes, the reverse key it added could never reach a core, and the previous
commit's claim that the node-push paths go through it was wrong.

The four account maps that do reach buildUserAccount are untouched. Reported by
the PR review.
2026-09-15 18:00:54 +03:00

150 lines
5.1 KiB
Go

package service
import (
"context"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// reverseUserProbe records the account maps the panel pushes to the core: the
// last place a stored reverse tag can be dropped before it reaches a listener.
type reverseUserProbe struct {
fakeNodeRuntime
mu sync.Mutex
users []map[string]any
}
func (p *reverseUserProbe) AddUser(_ context.Context, _ *model.Inbound, user map[string]any) error {
p.mu.Lock()
defer p.mu.Unlock()
p.users = append(p.users, user)
return nil
}
func (p *reverseUserProbe) recorded() []map[string]any {
p.mu.Lock()
defer p.mu.Unlock()
return append([]map[string]any(nil), p.users...)
}
const reverseProbeID = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a333"
func reverseProbeClient(email string, enable bool) model.Client {
return model.Client{Email: email, ID: reverseProbeID, Enable: enable, Reverse: &model.ClientReverse{Tag: "portal"}}
}
// seedReverseProbeInbound seeds one local vless inbound holding a single reverse
// client, and the recording runtime every local apply of it lands on.
func seedReverseProbeInbound(t *testing.T, tag string, port int, enable bool) (*model.Inbound, string, *reverseUserProbe) {
t.Helper()
setupConflictDB(t)
mgr := useTestRuntimeManager(t)
probe := &reverseUserProbe{}
mgr.SetLocalRuntimeOverride(probe)
email := tag + "@example.test"
client := reverseProbeClient(email, enable)
seedInboundConflict(t, tag, "0.0.0.0", port, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, []model.Client{client}))
inbound := loadInboundByTag(t, tag)
if err := (&ClientService{}).SyncInbound(nil, inbound.Id, []model.Client{client}); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
return inbound, email, probe
}
// assertReverseReAdd fails unless the core was handed the client's own tag: the
// handler is gone the moment RemoveUser runs, and only the tag rebuilds it.
func assertReverseReAdd(t *testing.T, probe *reverseUserProbe, email string) {
t.Helper()
users := probe.recorded()
if len(users) == 0 {
t.Fatalf("%s was never re-added to the core, so its reverse tag was never checked", email)
}
found := false
for _, user := range users {
if got, _ := user["email"].(string); got != email {
continue
}
found = true
tag, _ := user["reverse"].(*model.ClientReverse)
if tag == nil || tag.Tag != "portal" {
t.Fatalf("the re-add of %s carries reverse %#v, want its stored tag portal", email, user["reverse"])
}
}
if !found {
t.Fatalf("no re-add of %s reached the core: %v", email, users)
}
}
// The panel's most ordinary action on a reverse client: editing it removes the
// account and adds it back, and the core rebuilds nothing without the tag.
func TestClientEditKeepsTheReverseTag(t *testing.T) {
_, email, probe := seedReverseProbeInbound(t, "rev-edit", 50071, true)
rec := lookupClientRecord(t, email)
edited := reverseProbeClient(email, true)
edited.Comment = "edited after the tunnel was up"
if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, edited, 0); err != nil {
t.Fatalf("Update: %v", err)
}
assertReverseReAdd(t, probe, email)
}
func TestBulkReEnableKeepsTheReverseTag(t *testing.T) {
_, email, probe := seedReverseProbeInbound(t, "rev-bulk", 50072, false)
if _, _, err := (&ClientService{}).BulkSetEnable(&InboundService{}, []string{email}, true); err != nil {
t.Fatalf("BulkSetEnable: %v", err)
}
assertReverseReAdd(t, probe, email)
}
// The route an operator hits most often: a client that exhausted its quota is
// removed, then re-added by the reset that renews it.
func TestTrafficResetKeepsTheReverseTag(t *testing.T) {
inbound, email, probe := seedReverseProbeInbound(t, "rev-quota", 50073, true)
depleteClientTraffic(t, inbound.Id, email)
if _, err := (&InboundService{}).ResetClientTraffic(inbound.Id, email); err != nil {
t.Fatalf("ResetClientTraffic: %v", err)
}
assertReverseReAdd(t, probe, email)
}
func TestAddingClientsKeepsTheReverseTag(t *testing.T) {
inbound, _, probe := seedReverseProbeInbound(t, "rev-add", 50074, true)
const added = "rev-add-second@example.test"
second := reverseProbeClient(added, true)
second.ID = "7c3fad07-4b1c-4d66-9f83-7db2f3c8b444"
if _, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{
Id: inbound.Id,
Protocol: model.VLESS,
Settings: clientsSettings(t, []model.Client{second}),
}); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
assertReverseReAdd(t, probe, added)
}
// depleteClientTraffic leaves the client enabled in settings but out of quota,
// the state a traffic reset re-adds it from.
func depleteClientTraffic(t *testing.T, inboundId int, email string) {
t.Helper()
db := database.GetDB()
res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
Updates(map[string]any{"enable": false, "up": 1, "down": 1})
if res.Error != nil {
t.Fatalf("deplete traffic: %v", res.Error)
}
if res.RowsAffected == 0 {
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil {
t.Fatalf("create depleted traffic: %v", err)
}
}
}