Files
3x-ui/internal/web/service/client_delete_tombstone_test.go
T
Farhan Zare 47be06358e fix(clients): withdraw delete tombstones for clients any serialized write re-creates
Deleting a client tombstones its email for 90s, so a node snapshot taken
before the delete reached the node cannot resurrect it. Client create and
bulk create withdrew that tombstone, but a client re-created through
InboundService.AddInbound (SyncInbound / syncInboundClients) did not. On a
master, re-adding a node inbound with a just-deleted email therefore let
the next node merge filter the new client out of the snapshot, unlink it
from its inbound and mark it a sync orphan; ReapSyncOrphans then
hard-deleted it 15 minutes later and the node lost it.

syncInboundClients now records the emails it creates on the serialized
transaction, and runSerializedTx withdraws their tombstones only after the
commit, still on the writer, so no merge runs in between and a rolled-back
create leaves the tombstone standing. The per-call withdraws in Create and
bulkCreate are covered by this and removed.

Co-authored-by: FZ1010 <FZ1010@users.noreply.github.com>
2026-10-23 19:30:13 +02:00

200 lines
8.1 KiB
Go

package service
import (
"errors"
"fmt"
"testing"
"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")
}
}