Files
3x-ui/internal/web/service/inbound_migration.go
T
iYuan 2a7342baa9 feat: add inbound share address strategy (#5162)
* feat: add inbound share address strategy

Allow node-managed inbounds to choose whether exported share links use the node address, routable listen address, or a custom endpoint. Preserve locally configured share address fields during remote node traffic sync.

Refs #5161

Refs #4891

* fix: preserve inbound share address settings

Forward share address fields to remote nodes, keep existing values when older update payloads omit them, align localhost handling between frontend and subscriptions, and preserve share address settings when cloning inbounds.

* fix: keep share address strategy out of subscriptions

Limit the new share address strategy to direct exported share links and QR codes. Restore subscription address resolution to the existing panel-owned behavior and update the UI help text accordingly.

* fix: address share address review feedback

* fix: validate custom share address

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 20:24:15 +02:00

257 lines
8.1 KiB
Go

package service
import (
"encoding/json"
"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 %s %s)",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
db.Exec(query)
}
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 && 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 {
var tgId any = 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.Raw(tagCleanup).Error
if err != nil {
return
}
}
func (s *InboundService) MigrateDB() {
s.MigrationRequirements()
s.MigrationRemoveOrphanedTraffics()
}