Files
3x-ui/internal/web/service/inbound_migration.go
T
n0ctal 3b9ca47a4e fix(database): keep the legacy tag cleanup from colliding with an existing tag (#6592)
* fix(database): avoid legacy inbound tag cleanup collisions

* test(database): assert the legacy tag cleanup keeps the migration green

The collision guard's test asserted only that the colliding tag was left
alone, which an unguarded cleanup also produces: the UPDATE fails on the
unique index and the row is unchanged either way. The cleanup shares a
transaction with every other requirement, so that failure rolls all of
them back on every boot and only reaches the log. Assert the call itself
succeeds, which is what actually distinguishes the two.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-09-26 20:30:25 +02:00

339 lines
11 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() (err error) {
db := database.GetDB()
tx := db.Begin()
defer func() {
if err == nil {
if commitErr := tx.Commit().Error; commitErr != nil {
err = commitErr
return
}
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) error {
return 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)).Error
}
for _, column := range [][2]string{{"inbounds", "enable"}, {"client_traffics", "enable"}, {"nodes", "enable"}, {"clients", "enable"}, {"api_tokens", "enabled"}, {"outbound_subscriptions", "enabled"}} {
if err = normalizeBool(column[0], column[1]); err != nil {
return
}
}
}
// 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")
}
var modifiedSettings []byte
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
var modelClients []model.Client
modelClients, err = s.GetClients(inbounds[inbound_index])
if err != nil {
return
}
for _, modelClient := range modelClients {
if len(modelClient.Email) > 0 {
var count int64
if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count).Error; err != nil {
return
}
if count == 0 {
if err = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient); err != nil {
return
}
}
}
}
// Heal clients table for installs where the one-shot seeder
// skipped clients due to a tgId-string unmarshal error.
if err = s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); err != nil {
return
}
}
if err = tx.Save(inbounds).Error; err != nil {
return
}
// Remove orphaned traffics
if err = tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}).Error; err != nil {
return
}
// 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, marshalErr := json.MarshalIndent(stream, " ", " ")
if marshalErr != nil {
err = marshalErr
return
}
if err = tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream).Error; err != nil {
return
}
}
// 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
AND NOT EXISTS (
SELECT 1 FROM inbounds AS other
WHERE other.id <> inbounds.id
AND other.tag = REPLACE(inbounds.tag, '0.0.0.0:', '')
);`
if database.IsPostgres() {
tagCleanup = `UPDATE inbounds AS i
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE position('0.0.0.0:' in tag) > 0
AND NOT EXISTS (
SELECT 1 FROM inbounds AS other
WHERE other.id <> i.id
AND other.tag = REPLACE(i.tag, '0.0.0.0:', '')
);`
}
err = tx.Exec(tagCleanup).Error
if err != nil {
return
}
return err
}
func (s *InboundService) MigrateDB() {
if err := s.MigrationRequirements(); err != nil {
logger.Errorf("MigrationRequirements failed: %v", err)
}
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 {
if ib.DisableFlow {
continue
}
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)
}
}