mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
8b525b5be6
A client with an inbound link is not an orphan, but the node sweep's mark was cleared only at the end of a node merge. An admin attach left a stale mark, and ReapSyncOrphans deleted the client at its next detach, traffic and IPs included, without the grace window. Inserting a link now clears the mark of the clients it links, and the reaper rereads its candidates inside its writer, so an attach that commits after the list was read is not reaped.
204 lines
7.5 KiB
Go
204 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
|
|
t.Helper()
|
|
var row model.ClientRecord
|
|
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
|
t.Fatalf("read client %q: %v", email, err)
|
|
}
|
|
return row.SyncOrphanedAt
|
|
}
|
|
|
|
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
|
t.Helper()
|
|
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
|
|
if err := db.Model(&model.ClientRecord{}).
|
|
Where("email = ?", email).
|
|
Update("sync_orphaned_at", past).Error; err != nil {
|
|
t.Fatalf("backdate orphan mark: %v", err)
|
|
}
|
|
}
|
|
|
|
// A partial snapshot (node alive, still serving another client) authoritatively drops one;
|
|
// the merge soft-orphans, recoverable until the grace elapses and the reaper confirms it.
|
|
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
clientSvc := &ClientService{}
|
|
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
const email = "gone@x"
|
|
const keep = "keep@x"
|
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
|
|
bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
|
xray.ClientTraffic{Email: keep, Enable: true},
|
|
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
|
|
|
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
|
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
|
}
|
|
|
|
keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
|
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
|
|
xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
|
|
t.Fatalf("orphaning merge: %v", err)
|
|
}
|
|
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
|
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
|
}
|
|
if readOrphanMark(t, db, email) <= 0 {
|
|
t.Fatal("merge did not stamp sync_orphaned_at")
|
|
}
|
|
|
|
reaped, err := clientSvc.ReapSyncOrphans()
|
|
if err != nil {
|
|
t.Fatalf("reap inside grace: %v", err)
|
|
}
|
|
if reaped != 0 {
|
|
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
|
|
}
|
|
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
|
t.Fatal("client removed before the grace period elapsed")
|
|
}
|
|
|
|
backdateOrphanMark(t, db, email)
|
|
reaped, err = clientSvc.ReapSyncOrphans()
|
|
if err != nil {
|
|
t.Fatalf("reap after grace: %v", err)
|
|
}
|
|
if reaped != 1 {
|
|
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
|
|
}
|
|
rec, traf := countClientRows(t, db, email)
|
|
if rec != 0 || traf != 0 {
|
|
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
|
|
}
|
|
}
|
|
|
|
// A client the node reports again (partial snapshot) was never gone: clearing the mark
|
|
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
|
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
clientSvc := &ClientService{}
|
|
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
const email = "flaky@x"
|
|
const keep = "keep@x"
|
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
|
|
bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
|
xray.ClientTraffic{Email: keep, Enable: true},
|
|
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
|
|
|
keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
|
|
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
|
|
xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
|
|
t.Fatalf("orphaning merge: %v", err)
|
|
}
|
|
if readOrphanMark(t, db, email) <= 0 {
|
|
t.Fatal("setup: expected the merge to mark the client")
|
|
}
|
|
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
|
xray.ClientTraffic{Email: keep, Enable: true},
|
|
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
|
|
|
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
|
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
|
|
}
|
|
|
|
backdateOrphanMark(t, db, email)
|
|
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
|
|
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
|
|
}
|
|
}
|
|
|
|
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
|
|
// mark and keep their existing manual-cleanup semantics.
|
|
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
clientSvc := &ClientService{}
|
|
|
|
const email = "manual@x"
|
|
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
|
|
if err := db.Create(rec).Error; err != nil {
|
|
t.Fatalf("create client: %v", err)
|
|
}
|
|
|
|
reaped, err := clientSvc.ReapSyncOrphans()
|
|
if err != nil {
|
|
t.Fatalf("reap: %v", err)
|
|
}
|
|
if reaped != 0 {
|
|
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
|
|
}
|
|
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("unmarked orphan was reaped: %d rows survive, want 1", surviving)
|
|
}
|
|
}
|
|
|
|
// With its only node silent no merge runs, so an admin's attach has to clear the mark
|
|
// itself, or the client's next detach is reaped instead of kept for manual cleanup.
|
|
func TestAdminAttachClearsSyncOrphanMark(t *testing.T) {
|
|
db := initTrafficTestDB(t)
|
|
svc := &InboundService{}
|
|
clientSvc := &ClientService{}
|
|
|
|
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
|
|
|
const email = "rescued@x"
|
|
const keep = "keep@x"
|
|
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
|
|
bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
|
|
xray.ClientTraffic{Email: keep, Enable: true},
|
|
xray.ClientTraffic{Email: email, Enable: true})
|
|
syncNodeWithSettings(t, svc, 1, "n1-in", fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep),
|
|
xray.ClientTraffic{Email: keep, Enable: true})
|
|
if readOrphanMark(t, db, email) <= 0 {
|
|
t.Fatal("setup: expected the merge to mark the client")
|
|
}
|
|
backdateOrphanMark(t, db, email)
|
|
|
|
master := &model.Inbound{UserId: 1, Tag: "master-in", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
|
if err := db.Create(master).Error; err != nil {
|
|
t.Fatalf("create master inbound: %v", err)
|
|
}
|
|
if err := clientSvc.ApplyInboundClientDelta(db, master.Id, []model.Client{{Email: email, Enable: true}}, nil); err != nil {
|
|
t.Fatalf("attach: %v", err)
|
|
}
|
|
if at := readOrphanMark(t, db, email); at != 0 {
|
|
t.Fatalf("attached client kept its orphan mark: sync_orphaned_at=%d", at)
|
|
}
|
|
|
|
if err := clientSvc.ApplyInboundClientDelta(db, master.Id, nil, []string{email}); err != nil {
|
|
t.Fatalf("detach: %v", err)
|
|
}
|
|
reaped, err := clientSvc.ReapSyncOrphans()
|
|
if err != nil {
|
|
t.Fatalf("reap: %v", err)
|
|
}
|
|
if rec, _ := countClientRows(t, db, email); reaped != 0 || rec != 1 {
|
|
t.Fatalf("admin-detached client was reaped: reaped=%d clients=%d, want 0/1", reaped, rec)
|
|
}
|
|
}
|