mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-15 18:35:00 +03:00
fix(node-sync): write only changed client rows in the node traffic merge
Every 5s node merge rewrote one client_traffics row and one node_client_traffics baseline per (inbound, client) pair the node reported, even when nothing had moved: a client is reported once per inbound it is on, and idle clients every tick. On top of that each inbound's SyncInbound wrote its own per-inbound values onto the shared clients record, so a client on a flow and a non-flow inbound (or on a WireGuard tunnel plus any other inbound) had its record flipped back and forth twice per merge. With 7 nodes and ~20k client links that is tens of thousands of statements per tick, all inside transactions on the single serialized writer, holding row locks for seconds. A renew of a client on 38 inbounds takes one writer turn per inbound, queued behind those merges each time, and outlived the 30s HTTP write timeout even though it committed; last_sub_fetch updates waited on the same locks. The merge now computes the row its UPDATE would leave, mirroring the SQL merge expressions, and skips the write when nothing changes; the baseline upsert is skipped when the node counter has not moved. The record's per-inbound fields get one owner each, so repeated syncs agree: flow is settled to the effective flow (lowest-id link carrying one) for clients whose links changed, and WireGuard/AmneziaWG peer fields come from the client's lowest-id tunnel. A repeated unchanged snapshot now writes no client row; one active client writes two. Verified beyond make verify: the CI Postgres lane, the merge and flow tests run against PostgreSQL 16, and make node-e2e. Closes #6815
This commit is contained in:
@@ -324,3 +324,35 @@ func TestBulkAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(t *testing.T) {
|
|||||||
t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
|
t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A client detached from its last inbound has no effective flow left to settle
|
||||||
|
// its record to, so the record keeps the flow a later attach falls back on.
|
||||||
|
func TestRecordFlow_DetachedFromLastInboundKeepsFlow(t *testing.T) {
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||||
|
|
||||||
|
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 43001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
|
||||||
|
if err := database.GetDB().Create(reality).Error; err != nil {
|
||||||
|
t.Fatalf("create reality inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := ClientService{}
|
||||||
|
const email = "detached@example.com"
|
||||||
|
const vision = "xtls-rprx-vision"
|
||||||
|
client := model.Client{Email: email, ID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333", Enable: true, Flow: vision}
|
||||||
|
if err := svc.SyncInbound(nil, reality.Id, []model.Client{client}); err != nil {
|
||||||
|
t.Fatalf("SyncInbound(attach): %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.SyncInbound(nil, reality.Id, nil); err != nil {
|
||||||
|
t.Fatalf("SyncInbound(detach): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := svc.GetRecordByEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRecordByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Flow != vision {
|
||||||
|
t.Errorf("record flow after detaching its last inbound = %q, want %q", rec.Flow, vision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
tx = database.GetDB()
|
tx = database.GetDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil {
|
var inbound model.Inbound
|
||||||
|
if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +141,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
|
||||||
|
var onLowerTunnel map[int]struct{}
|
||||||
|
if isTunnel && len(existing) > 0 {
|
||||||
|
ids := make([]int, 0, len(existing))
|
||||||
|
for _, row := range existing {
|
||||||
|
ids = append(ids, row.Id)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
idByEmail := make(map[string]int, len(emails))
|
idByEmail := make(map[string]int, len(emails))
|
||||||
pending := make(map[string]*model.ClientRecord, len(emails))
|
pending := make(map[string]*model.ClientRecord, len(emails))
|
||||||
toCreate := make([]*model.ClientRecord, 0, len(emails))
|
toCreate := make([]*model.ClientRecord, 0, len(emails))
|
||||||
@@ -161,6 +178,8 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
|
|
||||||
before := *row
|
before := *row
|
||||||
applyClientRecordMerge(row, incoming)
|
applyClientRecordMerge(row, incoming)
|
||||||
|
_, outranked := onLowerTunnel[row.Id]
|
||||||
|
keepLinkOwnedFields(row, &before, isTunnel && !outranked)
|
||||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||||
row.UpdatedAt = preservedUpdatedAt
|
row.UpdatedAt = preservedUpdatedAt
|
||||||
|
|
||||||
@@ -222,7 +241,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
wantedIds = append(wantedIds, id)
|
wantedIds = append(wantedIds, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
|
relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := settleRecordFlows(tx, relinked); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,19 +283,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
|
// reconcileInboundLinks writes only the client_inbounds rows that differ and returns
|
||||||
// also removes links absent from wantedFlow, which only a full sync may do.
|
// their clients. prune also removes links absent from wantedFlow, as only a full sync may.
|
||||||
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error {
|
func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
|
||||||
var current []model.ClientInbound
|
var current []model.ClientInbound
|
||||||
if prune {
|
if prune {
|
||||||
if err := tx.Where("inbound_id = ?", inboundId).Find(¤t).Error; err != nil {
|
if err := tx.Where("inbound_id = ?", inboundId).Find(¤t).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
|
for _, batch := range chunkInts(wantedIds, sqlInChunk) {
|
||||||
var rows []model.ClientInbound
|
var rows []model.ClientInbound
|
||||||
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
|
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
current = append(current, rows...)
|
current = append(current, rows...)
|
||||||
}
|
}
|
||||||
@@ -300,7 +323,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
|
for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
|
||||||
var ids []int
|
var ids []int
|
||||||
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
|
if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if _, keep := wantedFlow[id]; !keep {
|
if _, keep := wantedFlow[id]; !keep {
|
||||||
@@ -325,7 +348,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
for _, batch := range chunkInts(toDelete, sqlInChunk) {
|
for _, batch := range chunkInts(toDelete, sqlInChunk) {
|
||||||
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
||||||
Delete(&model.ClientInbound{}).Error; err != nil {
|
Delete(&model.ClientInbound{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for flow, ids := range toUpdate {
|
for flow, ids := range toUpdate {
|
||||||
@@ -333,7 +356,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
if err := tx.Model(&model.ClientInbound{}).
|
if err := tx.Model(&model.ClientInbound{}).
|
||||||
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
|
||||||
Update("flow_override", flow).Error; err != nil {
|
Update("flow_override", flow).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,8 +367,87 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
|
|||||||
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
|
Columns: []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
|
DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
|
||||||
}).CreateInBatches(toInsert, 200).Error; err != nil {
|
}).CreateInBatches(toInsert, 200).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relinked := append([]int(nil), toDelete...)
|
||||||
|
for _, ids := range toUpdate {
|
||||||
|
relinked = append(relinked, ids...)
|
||||||
|
}
|
||||||
|
for _, link := range toInsert {
|
||||||
|
relinked = append(relinked, link.ClientId)
|
||||||
|
}
|
||||||
|
return relinked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
|
||||||
|
// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
|
||||||
|
func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
|
||||||
|
row.Flow = before.Flow
|
||||||
|
if ownsTunnelPeer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.PrivateKey = before.PrivateKey
|
||||||
|
row.PublicKey = before.PublicKey
|
||||||
|
row.AllowedIPs = before.AllowedIPs
|
||||||
|
row.PreSharedKey = before.PreSharedKey
|
||||||
|
row.KeepAlive = before.KeepAlive
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
|
||||||
|
// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
|
||||||
|
func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
|
||||||
|
out := make(map[int]struct{})
|
||||||
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
||||||
|
var ids []int
|
||||||
|
if err := tx.Table("client_inbounds").
|
||||||
|
Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
|
||||||
|
Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
|
||||||
|
batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
|
||||||
|
Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleRecordFlows sets the record flow of each still-linked client to its
|
||||||
|
// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
|
||||||
|
func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
|
||||||
|
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
||||||
|
var links []model.ClientInbound
|
||||||
|
if err := tx.Select("client_id", "flow_override").
|
||||||
|
Where("client_id IN ?", batch).
|
||||||
|
Order("inbound_id ASC").
|
||||||
|
Find(&links).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
effective := make(map[int]string, len(batch))
|
||||||
|
for _, link := range links {
|
||||||
|
if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
|
||||||
|
effective[link.ClientId] = link.FlowOverride
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var records []model.ClientRecord
|
||||||
|
if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
idsByFlow := make(map[string][]int)
|
||||||
|
for _, rec := range records {
|
||||||
|
if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
|
||||||
|
idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for flow, ids := range idsByFlow {
|
||||||
|
if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
|
||||||
|
UpdateColumn("flow", flow).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -418,12 +520,8 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
|
|||||||
|
|
||||||
// validateProtocolClients runs inside the write every path linking clients to an inbound
|
// validateProtocolClients runs inside the write every path linking clients to an inbound
|
||||||
// shares, so a client the protocol's core config would refuse never reaches it.
|
// shares, so a client the protocol's core config would refuse never reaches it.
|
||||||
func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
|
||||||
var inbound model.Inbound
|
switch protocol {
|
||||||
if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch inbound.Protocol {
|
|
||||||
case model.TUIC:
|
case model.TUIC:
|
||||||
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
|
return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
|
||||||
case model.MASQUE:
|
case model.MASQUE:
|
||||||
|
|||||||
@@ -224,6 +224,43 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
|
|||||||
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeNodeClientTraffic folds one node-reported client into its central row.
|
||||||
|
// While frozen (a push pending or just landed) only the counters may move.
|
||||||
|
func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, frozen bool, deltaUp, deltaDown, now int64) error {
|
||||||
|
if frozen {
|
||||||
|
return tx.Exec(
|
||||||
|
fmt.Sprintf(
|
||||||
|
`UPDATE client_traffics
|
||||||
|
SET up = %s, down = %s, last_online = %s
|
||||||
|
WHERE email = ?`,
|
||||||
|
database.ClampedAddExpr("up"),
|
||||||
|
database.ClampedAddExpr("down"),
|
||||||
|
database.GreatestExpr("last_online", "?"),
|
||||||
|
),
|
||||||
|
deltaUp, deltaDown, cs.LastOnline, cs.Email,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
|
return tx.Exec(
|
||||||
|
fmt.Sprintf(
|
||||||
|
`UPDATE client_traffics
|
||||||
|
SET up = %s, down = %s, enable = %s, total = ?,
|
||||||
|
expiry_time = %s,
|
||||||
|
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
|
||||||
|
WHERE email = ?`,
|
||||||
|
database.ClampedAddExpr("up"),
|
||||||
|
database.ClampedAddExpr("down"),
|
||||||
|
database.ClientTrafficEnableMergeExpr(),
|
||||||
|
database.ClientTrafficExpiryMergeExpr(),
|
||||||
|
database.GreatestExpr("last_online", "?"),
|
||||||
|
),
|
||||||
|
deltaUp, deltaDown,
|
||||||
|
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
|
||||||
|
cs.Total,
|
||||||
|
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
|
||||||
|
cs.LastOnline, cs.Email,
|
||||||
|
).Error
|
||||||
|
}
|
||||||
|
|
||||||
// mergeActivationExpiry: master absolute wins; node may only activate when
|
// mergeActivationExpiry: master absolute wins; node may only activate when
|
||||||
// master is unset/duration. Node auto-renew goes through nodeClientRenewed.
|
// master is unset/duration. Node auto-renew goes through nodeClientRenewed.
|
||||||
func mergeActivationExpiry(existing, node int64) int64 {
|
func mergeActivationExpiry(existing, node int64) int64 {
|
||||||
@@ -634,22 +671,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership set for the rowExists checks below. Only the snapshot's emails
|
// The stored row of every reported email, wherever it is homed, so the merge
|
||||||
// are ever probed, so scope the lookup to those instead of plucking the whole
|
// can skip a write that would leave it unchanged. Homed rows share centralCS's pointer.
|
||||||
// client_traffics table (50k+ rows) on every node poll.
|
statByEmail := make(map[string]*xray.ClientTraffic, len(snapEmailsAll))
|
||||||
existingEmails := make(map[string]struct{}, len(snapEmailsAll))
|
|
||||||
if len(snapEmailsAll) > 0 {
|
if len(snapEmailsAll) > 0 {
|
||||||
snapEmailList := make([]string, 0, len(snapEmailsAll))
|
snapEmailList := make([]string, 0, len(snapEmailsAll))
|
||||||
for email := range snapEmailsAll {
|
for email := range snapEmailsAll {
|
||||||
snapEmailList = append(snapEmailList, email)
|
snapEmailList = append(snapEmailList, email)
|
||||||
}
|
}
|
||||||
for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
|
for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
|
||||||
var found []string
|
var found []xray.ClientTraffic
|
||||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil {
|
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&found).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
for _, e := range found {
|
for i := range found {
|
||||||
existingEmails[e] = struct{}{}
|
if homed, ok := centralCSByEmail[found[i].Email]; ok {
|
||||||
|
statByEmail[found[i].Email] = homed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statByEmail[found[i].Email] = &found[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1064,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
if _, rowExists := statByEmail[cs.Email]; !rowExists {
|
||||||
if dirty {
|
if dirty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1062,7 +1102,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
}
|
}
|
||||||
centralCS[csKey{c.Id, cs.Email}] = row
|
centralCS[csKey{c.Id, cs.Email}] = row
|
||||||
centralCSByEmail[cs.Email] = row
|
centralCSByEmail[cs.Email] = row
|
||||||
existingEmails[cs.Email] = struct{}{}
|
statByEmail[cs.Email] = row
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -1121,64 +1161,34 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
existing.Total = cs.Total
|
existing.Total = cs.Total
|
||||||
existing.ExpiryTime = cs.ExpiryTime
|
existing.ExpiryTime = cs.ExpiryTime
|
||||||
existing.Reset = cs.Reset
|
existing.Reset = cs.Reset
|
||||||
|
existing.ResetDay = cs.ResetDay
|
||||||
existing.ResetWeekday = cs.ResetWeekday
|
existing.ResetWeekday = cs.ResetWeekday
|
||||||
existing.ResetCount = cs.ResetCount
|
existing.ResetCount = cs.ResetCount
|
||||||
|
existing.LastOnline = max(existing.LastOnline, cs.LastOnline)
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
} else if clientFrozen {
|
|
||||||
// Push pending or just landed: only counters may move, the master
|
|
||||||
// keeps expiry/enable/total/reset.
|
|
||||||
if err := tx.Exec(
|
|
||||||
fmt.Sprintf(
|
|
||||||
`UPDATE client_traffics
|
|
||||||
SET up = %s, down = %s, last_online = %s
|
|
||||||
WHERE email = ?`,
|
|
||||||
database.ClampedAddExpr("up"),
|
|
||||||
database.ClampedAddExpr("down"),
|
|
||||||
database.GreatestExpr("last_online", "?"),
|
|
||||||
),
|
|
||||||
deltaUp, deltaDown, cs.LastOnline, cs.Email,
|
|
||||||
).Error; err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
|
|
||||||
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
enableExpr := database.ClientTrafficEnableMergeExpr()
|
// The row as the UPDATE below would leave it, computed from its
|
||||||
expiryExpr := database.ClientTrafficExpiryMergeExpr()
|
// pre-update values exactly as the SQL reads them.
|
||||||
if err := tx.Exec(
|
row := statByEmail[cs.Email]
|
||||||
fmt.Sprintf(
|
next := *row
|
||||||
`UPDATE client_traffics
|
next.Up = clampTrafficCounter(row.Up + deltaUp)
|
||||||
SET up = %s, down = %s, enable = %s, total = ?,
|
next.Down = clampTrafficCounter(row.Down + deltaDown)
|
||||||
expiry_time = %s,
|
next.LastOnline = max(row.LastOnline, cs.LastOnline)
|
||||||
reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
|
if !clientFrozen {
|
||||||
WHERE email = ?`,
|
if !cs.Enable && !nodeDisableIsStale(row, cs, now, deltaUp, deltaDown) {
|
||||||
database.ClampedAddExpr("up"),
|
next.Enable = false
|
||||||
database.ClampedAddExpr("down"),
|
|
||||||
enableExpr,
|
|
||||||
expiryExpr,
|
|
||||||
database.GreatestExpr("last_online", "?"),
|
|
||||||
),
|
|
||||||
deltaUp, deltaDown,
|
|
||||||
cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
|
|
||||||
cs.Total,
|
|
||||||
cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
|
|
||||||
cs.LastOnline, cs.Email,
|
|
||||||
).Error; err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
priorExpiry := existing.ExpiryTime
|
|
||||||
if !cs.Enable && !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) {
|
|
||||||
existing.Enable = false
|
|
||||||
}
|
}
|
||||||
existing.ExpiryTime = mergeActivationExpiry(priorExpiry, cs.ExpiryTime)
|
next.ExpiryTime = mergeActivationExpiry(row.ExpiryTime, cs.ExpiryTime)
|
||||||
existing.Up = clampTrafficCounter(existing.Up + deltaUp)
|
next.Total = cs.Total
|
||||||
existing.Down = clampTrafficCounter(existing.Down + deltaDown)
|
next.Reset, next.ResetDay, next.ResetWeekday = cs.Reset, cs.ResetDay, cs.ResetWeekday
|
||||||
existing.Total = cs.Total
|
}
|
||||||
existing.Reset = cs.Reset
|
// A multi-attached client is reported once per inbound and an idle one
|
||||||
existing.ResetWeekday = cs.ResetWeekday
|
// every tick: rewriting an unchanged row only holds its lock longer.
|
||||||
|
if next != *row {
|
||||||
|
if err := s.mergeNodeClientTraffic(tx, cs, clientFrozen, deltaUp, deltaDown, now); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
*row = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
|
// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
|
||||||
@@ -1186,6 +1196,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
|
if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if seen && base == canon {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -471,6 +476,163 @@ func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
|
|||||||
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
|
assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clientWriteSQL = regexp.MustCompile(`(?i)^\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+["` + "`" + `]?(clients|client_inbounds|client_traffics|node_client_traffics)\b`)
|
||||||
|
|
||||||
|
// countClientRowWrites returns, per client table, how many rows fn's
|
||||||
|
// INSERT/UPDATE/DELETE statements touched.
|
||||||
|
func countClientRowWrites(t *testing.T, fn func()) map[string]int64 {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
var mu sync.Mutex
|
||||||
|
rows := map[string]int64{}
|
||||||
|
record := func(tx *gorm.DB) {
|
||||||
|
if tx.Error != nil || tx.RowsAffected == 0 || tx.Statement == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m := clientWriteSQL.FindStringSubmatch(tx.Statement.SQL.String()); m != nil {
|
||||||
|
mu.Lock()
|
||||||
|
rows[m[1]] += tx.RowsAffected
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = "test:count_client_row_writes"
|
||||||
|
cb := db.Callback()
|
||||||
|
for _, err := range []error{
|
||||||
|
cb.Create().After("gorm:create").Register(name, record),
|
||||||
|
cb.Update().After("gorm:update").Register(name, record),
|
||||||
|
cb.Delete().After("gorm:delete").Register(name, record),
|
||||||
|
cb.Raw().After("gorm:raw").Register(name, record),
|
||||||
|
} {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register write callback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, err := range []error{
|
||||||
|
cb.Create().Remove(name), cb.Update().Remove(name),
|
||||||
|
cb.Delete().Remove(name), cb.Raw().Remove(name),
|
||||||
|
} {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("remove write callback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeMerge_WritesOnlyChangedClients(t *testing.T) {
|
||||||
|
const clientCount = 20
|
||||||
|
type snapInbound struct {
|
||||||
|
tag string
|
||||||
|
protocol model.Protocol
|
||||||
|
entry func(i int, email string) map[string]any
|
||||||
|
}
|
||||||
|
vlessEntry := func(flow string) func(int, string) map[string]any {
|
||||||
|
return func(i int, email string) map[string]any {
|
||||||
|
e := map[string]any{"email": email, "id": fmt.Sprintf("%08d-0000-4000-8000-000000000000", i), "subId": email, "enable": true}
|
||||||
|
if flow != "" {
|
||||||
|
e["flow"] = flow
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wgEntry := func(tunnel int) func(int, string) map[string]any {
|
||||||
|
return func(i int, email string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"email": email, "subId": email, "enable": true,
|
||||||
|
"privateKey": fmt.Sprintf("priv-%d-%d", tunnel, i),
|
||||||
|
"publicKey": fmt.Sprintf("pub-%d-%d", tunnel, i),
|
||||||
|
"allowedIPs": []string{fmt.Sprintf("10.%d.0.%d/32", tunnel, i+2)},
|
||||||
|
"preSharedKey": fmt.Sprintf("psk-%d-%d", tunnel, i),
|
||||||
|
"keepAlive": 25 * tunnel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
inbounds []snapInbound
|
||||||
|
}{
|
||||||
|
{"vless with and without flow", []snapInbound{
|
||||||
|
{"n1-reality", model.VLESS, vlessEntry("xtls-rprx-vision")},
|
||||||
|
{"n1-ws", model.VLESS, vlessEntry("")},
|
||||||
|
}},
|
||||||
|
{"wireguard and vless", []snapInbound{
|
||||||
|
{"n1-wg", model.WireGuard, wgEntry(1)},
|
||||||
|
{"n1-vless", model.VLESS, vlessEntry("")},
|
||||||
|
}},
|
||||||
|
{"two wireguard tunnels", []snapInbound{
|
||||||
|
{"n1-wg-a", model.WireGuard, wgEntry(1)},
|
||||||
|
{"n1-wg-b", model.WireGuard, wgEntry(2)},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db := initTrafficTestDB(t)
|
||||||
|
svc := &InboundService{}
|
||||||
|
|
||||||
|
emails := make([]string, clientCount)
|
||||||
|
stats := make([]xray.ClientTraffic, clientCount)
|
||||||
|
for i := range emails {
|
||||||
|
emails[i] = fmt.Sprintf("multi-%d", i)
|
||||||
|
stats[i] = xray.ClientTraffic{Email: emails[i], Up: 1000, Down: 2000, Enable: true, LastOnline: 5000}
|
||||||
|
}
|
||||||
|
settings := make([]string, len(tc.inbounds))
|
||||||
|
for n, ib := range tc.inbounds {
|
||||||
|
entries := make([]map[string]any, clientCount)
|
||||||
|
for i, email := range emails {
|
||||||
|
entries[i] = ib.entry(i, email)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(map[string]any{"clients": entries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal settings: %v", err)
|
||||||
|
}
|
||||||
|
settings[n] = string(raw)
|
||||||
|
nodeID := 1
|
||||||
|
if err := db.Create(&model.Inbound{
|
||||||
|
UserId: 1, NodeID: &nodeID, Tag: ib.tag, Enable: true,
|
||||||
|
Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n],
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create inbound %q: %v", ib.tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merge := func(stats []xray.ClientTraffic) {
|
||||||
|
t.Helper()
|
||||||
|
// A node copies a multi-attached client's one counter onto every inbound it is on.
|
||||||
|
snap := &runtime.TrafficSnapshot{}
|
||||||
|
for n, ib := range tc.inbounds {
|
||||||
|
snap.Inbounds = append(snap.Inbounds, &model.Inbound{
|
||||||
|
Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol,
|
||||||
|
Settings: settings[n], ClientStats: slices.Clone(stats),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
|
||||||
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merge(stats)
|
||||||
|
if got := countClientRowWrites(t, func() { merge(stats) }); len(got) != 0 {
|
||||||
|
t.Errorf("repeating an unchanged snapshot wrote client rows %v, want none", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
active := slices.Clone(stats)
|
||||||
|
active[0].Up, active[0].Down, active[0].LastOnline = 1300, 2600, 6000
|
||||||
|
got := countClientRowWrites(t, func() { merge(active) })
|
||||||
|
want := map[string]int64{"client_traffics": 1, "node_client_traffics": 1}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("one client's traffic tick wrote client rows %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
ct := readTraffic(t, db, emails[0])
|
||||||
|
assertUpDown(t, ct, 300, 600, "active client after its tick")
|
||||||
|
if ct.LastOnline != 6000 {
|
||||||
|
t.Errorf("last_online = %d, want 6000", ct.LastOnline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
|
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
|
||||||
db := initTrafficTestDB(t)
|
db := initTrafficTestDB(t)
|
||||||
svc := &InboundService{}
|
svc := &InboundService{}
|
||||||
|
|||||||
@@ -94,12 +94,13 @@ func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
|
|||||||
|
|
||||||
wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
|
wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
|
||||||
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
|
awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
|
||||||
if err := db.Create(wgInbound).Error; err != nil {
|
// The lower-id tunnel owns the shared record's peer fields: AmneziaWG first.
|
||||||
t.Fatalf("create wg inbound: %v", err)
|
|
||||||
}
|
|
||||||
if err := db.Create(awgInbound).Error; err != nil {
|
if err := db.Create(awgInbound).Error; err != nil {
|
||||||
t.Fatalf("create awg inbound: %v", err)
|
t.Fatalf("create awg inbound: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.Create(wgInbound).Error; err != nil {
|
||||||
|
t.Fatalf("create wg inbound: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc := ClientService{}
|
svc := ClientService{}
|
||||||
if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
|
if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
|
||||||
@@ -202,7 +203,7 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
|
|||||||
t.Fatalf("read shared client: %v", err)
|
t.Fatalf("read shared client: %v", err)
|
||||||
}
|
}
|
||||||
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
|
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
|
||||||
t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
|
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
|
||||||
}
|
}
|
||||||
|
|
||||||
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
|
||||||
|
|||||||
Reference in New Issue
Block a user