mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
cfa8350d10
* 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.
150 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|