Files
3x-ui/internal/web/service/inbound_migration.go
Mr. Nickson ff954ec48c fix: stop deleting client_traffics for detached-but-alive clients (#6110)
* fix: stop deleting client_traffics for detached-but-alive clients

MigrationRemoveOrphanedTraffics keyed "orphaned" off presence in some
inbound's settings.clients[] JSON, a definition that predates #4469's
standalone clients table. ClientService.Detach intentionally keeps a
client's traffic row when it drops its last inbound attachment (so it
can be re-attached later without losing stats/expiry), but that client
has no entry in any inbound's JSON anymore - so every x-ui migrate run
or backup restore deleted its traffic row anyway, even though the
client itself was untouched and still listed. Scope the query to the
clients table instead, which is the function's actual intent.

Separately, frontend/src/hooks/useClients.ts recomputed the clients
summary from the client_stats WS snapshot as soon as it arrived, even
when that snapshot held fewer rows than the server's own total (e.g.
exactly the gap above, or any other client with no client_traffics
row). The recompute can only bucket the clients it was given, so the
missing ones silently fell out of every bucket while the headline
total still counted them - the Ended/Disabled cards read 0 and their
hover lists were empty even though the table below listed those rows,
leaving the Filter drawer as the only way to reach them. Extracted the
decision into pickClientsSummary and added the guard: fall back to the
server summary (built from the clients table, always sums to total)
whenever the snapshot doesn't cover every client.

Fixes #6102.

* fix: union both keep-sets instead of replacing (review feedback)

Address the automated review on this PR: switching
MigrationRemoveOrphanedTraffics to key solely off the clients table
traded the original bug for a worse one. The one-shot ClientsTable
seeder (internal/database/db.go) skips a client it fails to unmarshal
and never retries, so a client still live in an inbound's
settings.clients[] JSON can have no clients row at all - the new
predicate deleted its traffic row too, and an empty clients table
would have emptied client_traffics outright. Union both keep-sets: a
row survives if it's referenced by either the clients table or any
inbound's JSON, and is removed only when it's in neither.

Log the delete's outcome instead of discarding it silently, since a
whole-table wipe would otherwise leave no trace.

Rewrote the migration test as a table of all four combinations, driven
through real ClientService calls (SyncInbound, Detach) rather than
hand-built rows wherever a real path produces the state, so it tracks
actual behavior instead of an assumption about it. Added the missing
case the review flagged: a client live in JSON only, with no clients
row, must survive.

Also stripped the // comments this PR had added - CLAUDE.md states
committed Go/TS carries none, which the review separately flagged.
2026-07-28 22:14:01 +02:00

306 lines
10 KiB
Go

package service
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
db := database.GetDB()
query := fmt.Sprintf(
"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
result := db.Exec(query)
if result.Error != nil {
logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
return
}
if result.RowsAffected > 0 {
logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
}
}
func (s *InboundService) MigrationRequirements() {
db := database.GetDB()
tx := db.Begin()
var err error
defer func() {
if err == nil {
tx.Commit()
if !database.IsPostgres() {
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
logger.Warningf("VACUUM failed: %v", dbErr)
}
}
} else {
tx.Rollback()
}
}()
if tx.Migrator().HasColumn(&model.Inbound{}, "all_time") {
if err = tx.Migrator().DropColumn(&model.Inbound{}, "all_time"); err != nil {
return
}
}
if tx.Migrator().HasColumn(&xray.ClientTraffic{}, "all_time") {
if err = tx.Migrator().DropColumn(&xray.ClientTraffic{}, "all_time"); err != nil {
return
}
}
if err = normalizeInboundShareAddressColumns(tx); err != nil {
return
}
// Normalize "enable" columns to boolean on Postgres. Legacy SQLite data
// (0/1 integers), partial migrations, or mixed write paths (public API
// inbound updates that flow through UpdateClientStat + client syncs, plus
// node traffic merge deltas) can leave the column as integer or with mixed
// interpretation. This (combined with the dialect-aware
// ClientTrafficEnableMergeExpr) prevents type problems in the node traffic
// sync merge (SetRemoteTraffic) and makes the sync robust even when
// inbounds are updated via the public API (incl. ones carrying
// externalProxy in streamSettings). The same expression is also safe on
// SQLite (no PG :: casts).
if database.IsPostgres() {
// Use DO block so it is idempotent and doesn't fail if already boolean.
normalizeBool := func(table, col string) {
tx.Exec(fmt.Sprintf(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '%s' AND column_name = '%s'
AND data_type <> 'boolean'
) THEN
ALTER TABLE %s ALTER COLUMN %s
TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
END IF;
END $$;`, table, col, table, col, col))
}
normalizeBool("inbounds", "enable")
normalizeBool("client_traffics", "enable")
normalizeBool("nodes", "enable")
normalizeBool("clients", "enable")
normalizeBool("api_tokens", "enabled")
normalizeBool("outbound_subscriptions", "enabled")
}
// Fix inbounds based problems
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return
}
for inbound_index := range inbounds {
settings := map[string]any{}
_ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
if raw, exists := settings["clients"]; exists && raw == nil {
settings["clients"] = []any{}
}
clients, ok := settings["clients"].([]any)
if ok {
// Fix Client configuration problems
newClients := make([]any, 0, len(clients))
hasVisionFlow := false
for client_index := range clients {
c := clients[client_index].(map[string]any)
// Add email='' if it is not exists
if _, ok := c["email"]; !ok {
c["email"] = ""
}
// Convert string tgId to int64
if _, ok := c["tgId"]; ok {
tgId := c["tgId"]
if tgIdStr, ok2 := tgId.(string); ok2 {
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
if err == nil {
c["tgId"] = tgIdInt64
}
}
}
// Remove "flow": "xtls-rprx-direct"
if _, ok := c["flow"]; ok {
if c["flow"] == "xtls-rprx-direct" {
c["flow"] = ""
}
}
if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
hasVisionFlow = true
}
// Backfill created_at and updated_at
if _, ok := c["created_at"]; !ok {
c["created_at"] = time.Now().Unix() * 1000
}
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
settings["clients"] = newClients
// Drop orphaned testseed: VLESS-only field, only meaningful when at least
// one client uses the exact xtls-rprx-vision flow. Older versions saved it
// for any non-empty flow (including the UDP variant) or kept it after the
// flow was cleared from the client modal — clean those up here.
if inbounds[inbound_index].Protocol == model.VLESS && !hasVisionFlow {
delete(settings, "testseed")
}
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return
}
inbounds[inbound_index].Settings = string(modifiedSettings)
}
// Add client traffic row for all clients which has email
modelClients, err := s.GetClients(inbounds[inbound_index])
if err != nil {
return
}
for _, modelClient := range modelClients {
if len(modelClient.Email) > 0 {
var count int64
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
if count == 0 {
_ = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
}
}
}
// Heal clients table for installs where the one-shot seeder
// skipped clients due to a tgId-string unmarshal error.
if syncErr := s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); syncErr != nil {
logger.Warning("MigrationRequirements sync clients failed:", syncErr)
}
}
tx.Save(inbounds)
// Remove orphaned traffics
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
// Migrate old MultiDomain to External Proxy
var externalProxy []struct {
Id int
Port int
StreamSettings string // text column on both DBs; safer than []byte for cross-DB scan
}
externalProxyQuery := `select id, port, stream_settings
from inbounds
WHERE protocol in ('vmess','vless','trojan')
AND json_extract(stream_settings, '$.security') = 'tls'
AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`
if database.IsPostgres() {
externalProxyQuery = `select id, port, stream_settings
from inbounds
WHERE protocol in ('vmess','vless','trojan')
AND NULLIF(stream_settings, '')::jsonb #>> '{security}' = 'tls'
AND NULLIF(stream_settings, '')::jsonb #> '{tlsSettings,settings,domains}' IS NOT NULL`
}
err = tx.Raw(externalProxyQuery).Scan(&externalProxy).Error
if err != nil || len(externalProxy) == 0 {
return
}
for _, ep := range externalProxy {
var reverses any
var stream map[string]any
_ = json.Unmarshal([]byte(ep.StreamSettings), &stream)
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
if domains, ok := settings["domains"].([]any); ok {
for _, domain := range domains {
if domainMap, ok := domain.(map[string]any); ok {
domainMap["forceTls"] = "same"
domainMap["port"] = ep.Port
domainMap["dest"] = domainMap["domain"].(string)
delete(domainMap, "domain")
}
}
}
reverses = settings["domains"]
delete(settings, "domains")
}
}
stream["externalProxy"] = reverses
newStream, _ := json.MarshalIndent(stream, " ", " ")
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
}
// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
// Must be cross-DB: INSTR/REPLACE work on SQLite; Postgres needs position().
tagCleanup := `UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE INSTR(tag, '0.0.0.0:') > 0;`
if database.IsPostgres() {
tagCleanup = `UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE position('0.0.0.0:' in tag) > 0;`
}
err = tx.Exec(tagCleanup).Error
if err != nil {
return
}
}
func (s *InboundService) MigrateDB() {
s.MigrationRequirements()
s.MigrationRemoveOrphanedTraffics()
s.MigrationRestoreVisionFlow()
}
// MigrationRestoreVisionFlow repairs VLESS inbounds whose clients lost their
// XTLS Vision flow because the inbound was not flow-eligible when the client was
// written (e.g. an XHTTP inbound whose vlessenc encryption was enabled only
// later). For each now-eligible inbound it restores flow=xtls-rprx-vision on
// clients whose intended flow (their flow_override on a sibling inbound) is
// Vision. Idempotent: once a client carries the flow it is skipped, so this is a
// no-op on healthy installs and on subsequent boots.
func (s *InboundService) MigrationRestoreVisionFlow() {
db := database.GetDB()
var inbounds []*model.Inbound
if err := db.Model(&model.Inbound{}).
Where("protocol = ?", model.VLESS).
Find(&inbounds).Error; err != nil {
logger.Warning("MigrationRestoreVisionFlow: load inbounds failed:", err)
return
}
for _, ib := range inbounds {
restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol)
if !changed {
continue
}
clients, err := s.GetClients(&model.Inbound{Settings: restored})
if err != nil {
logger.Warning("MigrationRestoreVisionFlow: parse clients for inbound", ib.Id, "failed:", err)
continue
}
err = db.Transaction(func(tx *gorm.DB) error {
if e := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", restored).Error; e != nil {
return e
}
return s.clientService.SyncInbound(tx, ib.Id, clients)
})
if err != nil {
logger.Warning("MigrationRestoreVisionFlow: update inbound", ib.Id, "failed:", err)
continue
}
logger.Info("MigrationRestoreVisionFlow: restored XTLS Vision flow on inbound", ib.Id)
}
}