mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
44a502a407
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.
228 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|