Files
3x-ui/internal/web/service/client_delete_tombstone_test.go
T
MHSanaei 44a502a407 fix(clients): probe inbound settings with a short literal pattern
Deleting a client by an email no record holds fell back to a settings
LIKE '%email%' over every inbound with the raw route parameter, which
costs SQLite the settings length times the pattern length while it holds
the database; a long near-miss email froze the panel for minutes, and
wildcards were not escaped. The pre-filter now uses the first 64 bytes
of the email, escaped; the exact client match after it still decides.
2026-10-31 14:24:28 +01:00

228 lines
9.1 KiB
Go

package service
import (
"errors"
"fmt"
"strings"
"testing"
"time"
"gorm.io/gorm"
"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"
)
// Delete tombstones up front and keeps the record when an inbound fails. A
// surviving tombstone lets the next node merge finish the refused deletion.
func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
db := database.GetDB()
const email = "retry@x"
broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`)
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil {
t.Fatalf("attach client: %v", err)
}
t.Cleanup(func() { withdrawClientTombstones(email) })
if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil {
t.Fatal("setup: delete was expected to fail on the unparseable inbound settings")
}
var surviving int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if surviving != 1 {
t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving)
}
if isClientEmailTombstoned(email) {
t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
}
}
// A client re-created under a just-deleted email is a live identity again. If
// the tombstone outlives it, the node merge prunes the new client's link.
func TestRecreatedClientSurvivesNodeMerge(t *testing.T) {
db := initTrafficTestDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
const email = "reborn@x"
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
createNodeInboundWithClient(t, db, 1, "n1-in", 41501, email)
nodeSettings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
syncNodeWithSettings(t, inboundSvc, 1, "n1-in", nodeSettings, xray.ClientTraffic{Email: email, Enable: true})
var ib model.Inbound
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
rec := &model.ClientRecord{}
if err := db.Where("email = ?", email).First(rec).Error; err != nil {
t.Fatalf("load adopted client record: %v", err)
}
t.Cleanup(func() { withdrawClientTombstones(email) })
if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
t.Fatalf("delete client: %v", err)
}
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: email, Enable: true, ID: "44444444-4444-4444-4444-444444444444"},
InboundIds: []int{ib.Id},
}); err != nil {
t.Fatalf("re-create client: %v", err)
}
// The create marks the node config-dirty, which parks the client merge; the
// reconcile clears it a tick later, well inside the 90s tombstone window.
if err := db.Model(&model.Node{}).Where("id = ?", 1).Update("config_dirty", false).Error; err != nil {
t.Fatalf("clear config_dirty: %v", err)
}
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
Tag: "n1-in", Protocol: model.VLESS, Settings: nodeSettings,
ClientStats: []xray.ClientTraffic{{Email: email, Enable: true}},
}}}
if _, err := inboundSvc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("node merge: %v", err)
}
var links int64
if err := db.Model(&model.ClientInbound{}).
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ? AND client_inbounds.inbound_id = ?", email, ib.Id).
Count(&links).Error; err != nil {
t.Fatalf("count client links: %v", err)
}
if links != 1 {
t.Fatalf("re-created client has %d inbound links after the node merge, want 1: the delete tombstone outlived the email and the merge pruned it", links)
}
}
// Re-adding a node inbound with a just-deleted email creates that client again
// through AddInbound, so the next merge must not prune it as the deleted one.
func TestReaddedNodeInboundKeepsRecreatedClient(t *testing.T) {
db := initTrafficTestDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
const email = "tunnel-81@x"
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, InboundSyncMode: "all"})
recreated := fmt.Sprintf(`{"id": "55555555-5555-4555-8555-555555555555", "email": %q, "enable": true}`, email)
nodeInbound := func(clients string) *model.Inbound {
nodeID := 1
return &model.Inbound{
UserId: 1, NodeID: &nodeID, Enable: true, Listen: "127.0.0.1", Port: 18453, Protocol: model.VLESS,
Settings: `{"clients": [` + clients + `], "decryption": "none"}`,
StreamSettings: `{"network": "tcp", "security": "none"}`,
}
}
first, _, err := inboundSvc.AddInbound(nodeInbound(recreated))
if err != nil {
t.Fatalf("add node inbound: %v", err)
}
if _, err := inboundSvc.DelInbound(first.Id); err != nil {
t.Fatalf("delete node inbound: %v", err)
}
t.Cleanup(func() { withdrawClientTombstones(email) })
// inbounds/del only detaches; the record must go too or the re-add is a duplicate.
if _, err := clientSvc.DeleteByEmail(inboundSvc, email, false); err != nil {
t.Fatalf("delete detached client: %v", err)
}
// A second client keeps the tombstone-filtered snapshot non-empty, past the #6734 guard.
readded, _, err := inboundSvc.AddInbound(nodeInbound(recreated + `, {"id": "66666666-6666-4666-8666-666666666666", "email": "neighbour@x", "enable": true}`))
if err != nil {
t.Fatalf("re-add node inbound: %v", err)
}
// The next tick reconciles the push, clears dirty and merges a snapshot that has it.
if err := db.Model(&model.Node{}).Where("id = ?", 1).Update("config_dirty", false).Error; err != nil {
t.Fatalf("clear config_dirty: %v", err)
}
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
Tag: readded.Tag, Enable: true, Listen: readded.Listen, Port: readded.Port, Protocol: model.VLESS,
Settings: readded.Settings, StreamSettings: readded.StreamSettings,
ClientStats: []xray.ClientTraffic{{Email: email, Enable: true}, {Email: "neighbour@x", Enable: true}},
}}}
if _, err := inboundSvc.setRemoteTrafficLocked(1, snap, false, true); err != nil {
t.Fatalf("node merge: %v", err)
}
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("load re-created client: %v", err)
}
var links int64
if err := db.Model(&model.ClientInbound{}).
Where("client_id = ? AND inbound_id = ?", rec.Id, readded.Id).
Count(&links).Error; err != nil {
t.Fatalf("count client links: %v", err)
}
if links != 1 || rec.SyncOrphanedAt != 0 {
t.Fatalf("after the merge the re-added client has %d links to its inbound and sync_orphaned_at=%d, want 1 and 0: the delete tombstone outlived the re-add and the merge pruned the client for ReapSyncOrphans", links, rec.SyncOrphanedAt)
}
}
// The lift waits for the commit: a create that rolls back must leave the tombstone
// standing, or a snapshot taken before the delete resurrects the client.
func TestRolledBackCreateKeepsTombstone(t *testing.T) {
setupBulkDB(t)
const email = "rolled-back@x"
ib := mkInbound(t, 30451, model.VLESS, `{"clients": []}`)
tombstoneClientEmail(email)
t.Cleanup(func() { withdrawClientTombstones(email) })
errAbort := errors.New("abort after the client was created")
err := runSerializedTx(func(tx *gorm.DB) error {
created := []model.Client{{Email: email, ID: "77777777-7777-4777-8777-777777777777", Enable: true}}
if err := (&ClientService{}).SyncInbound(tx, ib.Id, created); err != nil {
return err
}
return errAbort
})
if !errors.Is(err, errAbort) {
t.Fatalf("runSerializedTx error = %v, want the injected abort", err)
}
if !isClientEmailTombstoned(email) {
t.Fatal("a create that rolled back withdrew the delete tombstone, so a stale node snapshot can resurrect the deleted client")
}
}
// The email reaches a LIKE pre-filter: a long near-miss costs SQLite text times pattern
// length while holding the database, and wildcards must not match either.
func TestFindInboundIdsByClientEmailTreatsTheEmailLiterally(t *testing.T) {
setupConflictDB(t)
settings := `{"clients":[{"email":"real@x"}],"pad":"` + strings.Repeat("a", 1<<20) + `"}`
if err := database.GetDB().Create(&model.Inbound{UserId: 1, Tag: "big", Port: 41601, Protocol: model.VLESS, Settings: settings}).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
done := make(chan []int, 1)
go func() {
ids, _ := (&ClientService{}).findInboundIdsByClientEmail(strings.Repeat("a", 10000) + "b")
done <- ids
}()
select {
case ids := <-done:
if len(ids) != 0 {
t.Fatalf("a wildcard email matched inbounds %v", ids)
}
case <-time.After(5 * time.Second):
t.Fatal("the email lookup is still running after 5 s")
}
if ids, err := (&ClientService{}).findInboundIdsByClientEmail("real@x"); err != nil || len(ids) != 1 {
t.Fatalf("lookup of a real email = %v, %v", ids, err)
}
}