From 837cf5f24e004382ec140cfb83206beb32f29b00 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 5 Jul 2026 20:33:09 +0200 Subject: [PATCH] fix(db): clamp traffic counters below int64 max and repair overflowed rows A counter pushed past int64 (multi-node setups hit this via historic delta-compounding bugs) makes SQLite silently promote the INTEGER cell to REAL. From then on the column no longer scans into the Go int64 field and every reader of client_traffics fails at once: the inbounds page, xray restarts, and node traffic sync all return "converting driver.Value type float64 to int64". Two-part fix: every unbounded "up = up + ?" add (local traffic, node delta merge, inbound counters, plus the Go-side outbound accumulation) now saturates at TrafficMax, a cap safely below math.MaxInt64 so one more delta cannot overflow; and a startup repair casts REAL-promoted cells back to INTEGER and clamps all traffic counters into [0, TrafficMax] across client_traffics, inbounds, outbound_traffics and node_client_traffics, restoring access to already-corrupted panels without manual sqlite surgery. Closes #5762 --- internal/database/db.go | 48 ++++++++ internal/database/dialect.go | 13 +++ .../database/traffic_overflow_repair_test.go | 103 ++++++++++++++++++ internal/web/service/inbound_node.go | 4 +- internal/web/service/inbound_traffic.go | 8 +- internal/web/service/outbound/outbound.go | 16 ++- 6 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 internal/database/traffic_overflow_repair_test.go diff --git a/internal/database/db.go b/internal/database/db.go index 1c034bb60..b67b75b4b 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -113,6 +113,9 @@ func initModels() error { if err := normalizeInboundSubSortIndex(); err != nil { return err } + if err := repairOverflowedTrafficCounters(); err != nil { + return err + } if err := migrateLegacySocksInboundsToMixed(); err != nil { return err } @@ -518,6 +521,51 @@ func normalizeInboundSubSortIndex() error { return nil } +// repairOverflowedTrafficCounters heals traffic counters that historic +// compounding bugs pushed past int64: on SQLite an overflowing INTEGER is +// silently promoted to REAL, after which the column no longer scans into the +// Go int64 field and every reader of the table fails (#5762). REAL cells are +// cast back to INTEGER (SQLite caps the cast at math.MaxInt64), then values +// are clamped into [0, TrafficMax] on both backends so the next delta cannot +// overflow again. +func repairOverflowedTrafficCounters() error { + targets := []struct { + table string + columns []string + }{ + {"client_traffics", []string{"up", "down"}}, + {"inbounds", []string{"up", "down"}}, + {"outbound_traffics", []string{"up", "down", "total"}}, + {"node_client_traffics", []string{"up", "down"}}, + } + for _, target := range targets { + for _, col := range target.columns { + statements := []string{ + fmt.Sprintf("UPDATE %s SET %s = %d WHERE %s > %d", target.table, col, TrafficMax, col, TrafficMax), + fmt.Sprintf("UPDATE %s SET %s = 0 WHERE %s < 0", target.table, col, col), + } + if !IsPostgres() { + statements = append([]string{ + fmt.Sprintf("UPDATE %s SET %s = CAST(%s AS INTEGER) WHERE typeof(%s) = 'real'", target.table, col, col, col), + }, statements...) + } + var repaired int64 + for _, statement := range statements { + res := db.Exec(statement) + if res.Error != nil { + log.Printf("Error repairing %s.%s: %v", target.table, col, res.Error) + return res.Error + } + repaired += res.RowsAffected + } + if repaired > 0 { + log.Printf("Repaired %d overflowed %s.%s value(s)", repaired, target.table, col) + } + } + } + return nil +} + func isIgnorableDuplicateColumnErr(err error, mdl any) bool { if err == nil { return false diff --git a/internal/database/dialect.go b/internal/database/dialect.go index 9d53ab64c..d142eb209 100644 --- a/internal/database/dialect.go +++ b/internal/database/dialect.go @@ -2,6 +2,19 @@ package database import "fmt" +// TrafficMax caps every traffic counter safely below math.MaxInt64 (~9.22e18) +// so that one more delta can never overflow int64. SQLite silently promotes an +// overflowing INTEGER to REAL, after which the column no longer scans into the +// Go int64 field and every reader of the table fails (#5762). +const TrafficMax = int64(9_000_000_000_000_000_000) + +func ClampedAddExpr(col string) string { + if IsPostgres() { + return fmt.Sprintf("LEAST(%s + ?, %d)", col, TrafficMax) + } + return fmt.Sprintf("MIN(%s + ?, %d)", col, TrafficMax) +} + func JSONClientsFromInbound() string { if IsPostgres() { return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)" diff --git a/internal/database/traffic_overflow_repair_test.go b/internal/database/traffic_overflow_repair_test.go new file mode 100644 index 000000000..9b4368853 --- /dev/null +++ b/internal/database/traffic_overflow_repair_test.go @@ -0,0 +1,103 @@ +package database + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// TestRepairOverflowedTrafficCounters_HealsSQLiteRealPromotion reproduces +// #5762: a counter pushed past int64 makes SQLite silently store the cell as +// REAL, after which scanning the row back into the Go int64 field fails and +// every reader of client_traffics breaks. The startup repair must convert the +// cell back to a scannable integer clamped to TrafficMax. +func TestRepairOverflowedTrafficCounters_HealsSQLiteRealPromotion(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + rows := []xray.ClientTraffic{ + {Email: "overflowed@x", Enable: true, Up: 5, Down: 6}, + {Email: "negative@x", Enable: true, Up: 7, Down: 8}, + {Email: "healthy@x", Enable: true, Up: 100, Down: 200}, + } + for i := range rows { + if err := db.Create(&rows[i]).Error; err != nil { + t.Fatalf("create traffic row %d: %v", i, err) + } + } + + if err := db.Exec("UPDATE client_traffics SET down = 1.2247589467272907e+19 WHERE email = 'overflowed@x'").Error; err != nil { + t.Fatalf("corrupt down: %v", err) + } + if err := db.Exec("UPDATE client_traffics SET up = -42 WHERE email = 'negative@x'").Error; err != nil { + t.Fatalf("corrupt up: %v", err) + } + + var broken []xray.ClientTraffic + if err := db.Find(&broken).Error; err == nil { + t.Fatal("expected the REAL-promoted row to break scanning before the repair") + } + + if err := repairOverflowedTrafficCounters(); err != nil { + t.Fatalf("repairOverflowedTrafficCounters: %v", err) + } + + byEmail := map[string]xray.ClientTraffic{} + var repaired []xray.ClientTraffic + if err := db.Find(&repaired).Error; err != nil { + t.Fatalf("scan after repair: %v", err) + } + for _, r := range repaired { + byEmail[r.Email] = r + } + if got := byEmail["overflowed@x"].Down; got != TrafficMax { + t.Errorf("overflowed down: expected clamp to %d, got %d", TrafficMax, got) + } + if got := byEmail["overflowed@x"].Up; got != 5 { + t.Errorf("overflowed up: expected untouched 5, got %d", got) + } + if got := byEmail["negative@x"].Up; got != 0 { + t.Errorf("negative up: expected clamp to 0, got %d", got) + } + if got := byEmail["healthy@x"]; got.Up != 100 || got.Down != 200 { + t.Errorf("healthy row changed: %+v", got) + } +} + +// TestClampedAddExpr_CapsAtTrafficMax verifies the write-path clamp: a delta +// applied to a counter near the cap must saturate at TrafficMax instead of +// overflowing int64 (which SQLite would promote to REAL). +func TestClampedAddExpr_CapsAtTrafficMax(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + row := xray.ClientTraffic{Email: "near-cap@x", Enable: true, Up: TrafficMax - 10, Down: 1} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create traffic row: %v", err) + } + + query := "UPDATE client_traffics SET up = " + ClampedAddExpr("up") + ", down = " + ClampedAddExpr("down") + " WHERE email = ?" + if err := db.Exec(query, int64(1_000_000), int64(5), "near-cap@x").Error; err != nil { + t.Fatalf("clamped add: %v", err) + } + + var got xray.ClientTraffic + if err := db.Where("email = ?", "near-cap@x").First(&got).Error; err != nil { + t.Fatalf("scan after clamped add: %v", err) + } + if got.Up != TrafficMax { + t.Errorf("up: expected saturation at %d, got %d", TrafficMax, got.Up) + } + if got.Down != 6 { + t.Errorf("down: expected 6, got %d", got.Down) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 4bdbe6a17..80662947a 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -744,10 +744,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi if err := tx.Exec( fmt.Sprintf( `UPDATE client_traffics - SET up = up + ?, down = down + ?, enable = %s, total = ?, + SET up = %s, down = %s, enable = %s, total = ?, expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END, reset = ?, last_online = %s WHERE email = ?`, + database.ClampedAddExpr("up"), + database.ClampedAddExpr("down"), enableExpr, database.GreatestExpr("last_online", "?"), ), diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index b5881d592..983f3484b 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -87,8 +87,8 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic if traffic.IsInbound { err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag). Updates(map[string]any{ - "up": gorm.Expr("up + ?", traffic.Up), - "down": gorm.Expr("down + ?", traffic.Down), + "up": gorm.Expr(database.ClampedAddExpr("up"), traffic.Up), + "down": gorm.Expr(database.ClampedAddExpr("down"), traffic.Down), }).Error if err != nil { return err @@ -153,7 +153,9 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr } if err = tx.Exec( fmt.Sprintf( - `UPDATE client_traffics SET up = up + ?, down = down + ?, last_online = %s WHERE email = ?`, + `UPDATE client_traffics SET up = %s, down = %s, last_online = %s WHERE email = ?`, + database.ClampedAddExpr("up"), + database.ClampedAddExpr("down"), database.GreatestExpr("last_online", "?"), ), t.Up, t.Down, now, ct.Email, diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index acdee8fa9..d37f8bf11 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -42,6 +42,16 @@ func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics [] return nil, false } +// saturatingAdd caps counters at database.TrafficMax: unlike the SQL paths, +// this read-modify-write add happens in Go, where an int64 overflow silently +// wraps negative instead of erroring (#5762). +func saturatingAdd(a, b int64) int64 { + if b > database.TrafficMax-a { + return database.TrafficMax + } + return a + b +} + func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error { if len(traffics) == 0 { return nil @@ -61,9 +71,9 @@ func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traff } outbound.Tag = traffic.Tag - outbound.Up = outbound.Up + traffic.Up - outbound.Down = outbound.Down + traffic.Down - outbound.Total = outbound.Up + outbound.Down + outbound.Up = saturatingAdd(outbound.Up, traffic.Up) + outbound.Down = saturatingAdd(outbound.Down, traffic.Down) + outbound.Total = saturatingAdd(outbound.Up, outbound.Down) err = tx.Save(&outbound).Error if err != nil {