mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 23:05:01 +03:00
47be06358e
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>
200 lines
8.1 KiB
Go
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")
|
|
}
|
|
}
|