Files
3x-ui-agent/internal/database/singbox_migrate.go
T
Muse 05c164dc8f B5 rehearsal fix: fragment tag = panel row tag
The rehearsal's inbound-level totals never moved: the agent
reports sing-box tags (inbound-<port>) while the master's agent
traffic handler matches inbound rows by their panel tag — which
the B1 model renamed to the n<node>-in- scheme, so the lookup
silently matched nothing (per-user totals, keyed by email, were
unaffected; production on V1 keeps the old tag form and is not
hit until the V2 migration itself).

The converter now takes the row's panel tag as the fragment tag
(migrator passes it through), making one identity serve the DB
row, the generated config, core stats and traffic reports. Bare
fixtures without a panel tag keep the historical form.
2026-10-23 02:15:36 +00:00

729 lines
24 KiB
Go

package database
// One-shot xray→sing-box data migration (ITEM6 B1).
//
// Runs once from InitDB, after initModels: when the settings marker
// "singboxMigrationVersion" is absent the database still holds the
// legacy xray-shaped model as its only truth. The migration
//
// 1. backs the database file up first (SQLite only; a failed backup
// refuses the migration and therefore startup),
// 2. migrates the template's four traffic-stats switches into their
// own settings key BEFORE the xrayTemplateConfig row is discarded
// (ITEM6-SCOPE §4.3.1/§4.3.2 — the order is load-bearing),
// 3. converts every inbound into a sing-box fragment + preserved
// sidecar (inbounds.singbox_config / singbox_preserved) using the
// identity layer (clients + client_inbounds) as the only truth,
// with the settings.clients mirror reconciled per spec §7-1,
// 4. converts agent-level outbound / routing overrides,
// 5. writes the marker LAST, all inside one transaction — any failure
// rolls everything back and InitDB refuses to start.
//
// Legacy columns are never modified (observation-period rule); the only
// deletions are the xrayTemplateConfig settings row (after its stats
// switches have been migrated out) and nothing else. Re-running against
// an already-migrated database is a no-op via the marker.
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/singbox"
"gorm.io/gorm"
)
const (
singboxMigrationVersion = "1"
singboxMigrationVersionKey = "singboxMigrationVersion"
trafficStatsSwitchesKey = "trafficStatsSwitches"
xrayTemplateSettingKey = "xrayTemplateConfig"
)
// FactoryXrayTemplate is the embedded factory-default xray template
// (web/service config.json). The web/service package publishes it here
// from an init function because internal/database cannot embed a file
// outside its own directory. When empty (database-only test binaries),
// the migration report records the template diff as unavailable and
// still retires the stored row.
var FactoryXrayTemplate string
// trafficStatsSwitches is the settings.trafficStatsSwitches payload
// (ITEM6 §4.3.1): the whole-network traffic-accounting switches that
// used to live in the xray template's policy.system.
type trafficStatsSwitches struct {
InboundUplink bool `json:"inboundUplink"`
InboundDownlink bool `json:"inboundDownlink"`
OutboundUplink bool `json:"outboundUplink"`
OutboundDownlink bool `json:"outboundDownlink"`
}
func defaultTrafficStatsSwitches() trafficStatsSwitches {
// Factory defaults (embedded template policy.system): inbound on,
// outbound off.
return trafficStatsSwitches{InboundUplink: true, InboundDownlink: true}
}
type migrationDiscrepancy struct {
InboundID int `json:"inboundId"`
Tag string `json:"tag"`
Email string `json:"email"`
Kind string `json:"kind"` // mirror-only-client | field-filled-from-mirror | credential-conflict | flow-source | wg-allowed-ips
Field string `json:"field,omitempty"`
Identity string `json:"identity,omitempty"`
Mirror string `json:"mirror,omitempty"`
}
type migrationUnmappedEntry struct {
InboundID int `json:"inboundId"`
Tag string `json:"tag"`
Protocol string `json:"protocol"`
Concept string `json:"concept"`
Pending string `json:"pending"`
Material bool `json:"material"`
}
type migrationNoteEntry struct {
InboundID int `json:"inboundId"`
Tag string `json:"tag"`
Text string `json:"text"`
}
type migrationAgentEntry struct {
NodeID int `json:"nodeId"`
Name string `json:"name"`
OutboundIssues []singbox.OverrideIssue `json:"outboundIssues,omitempty"`
RoutingIssues []singbox.OverrideIssue `json:"routingIssues,omitempty"`
}
type singboxMigrationReport struct {
Version string `json:"version"`
StartedAt string `json:"startedAt"`
Database string `json:"database"` // sqlite | postgres
Backup string `json:"backup,omitempty"`
InboundsTotal int `json:"inboundsTotal"`
InboundsByProtocol map[string]int `json:"inboundsByProtocol"`
Dispositions map[string]int `json:"dispositions"`
FragmentsWritten int `json:"fragmentsWritten"`
EndpointsWritten int `json:"endpointsWritten"`
ClientsProjected int `json:"clientsProjected"`
Discrepancies []migrationDiscrepancy `json:"discrepancies,omitempty"`
Unmapped []migrationUnmappedEntry `json:"unmapped,omitempty"`
Notes []migrationNoteEntry `json:"notes,omitempty"`
AgentNodes []migrationAgentEntry `json:"agentNodes,omitempty"`
StatsSource string `json:"statsSource"` // stored-template | factory-default
Stats trafficStatsSwitches `json:"stats"`
TemplatePresent bool `json:"templatePresent"`
TemplateNonDefault []string `json:"templateNonDefaultSections,omitempty"`
TemplateDiffAvailable bool `json:"templateDiffAvailable"`
ForwardingInbounds int `json:"forwardingInbounds"`
TraditionalNodes int64 `json:"traditionalNodes"`
ReportNotes []string `json:"reportNotes,omitempty"`
}
// runSingboxMigration is the InitDB hook. dbPath is the SQLite file path
// ("" for in-memory / temporary databases, where no file backup exists
// to take and nothing persistent can be lost).
func runSingboxMigration(dbPath string) error {
var marker model.Setting
err := db.Where("key = ?", singboxMigrationVersionKey).First(&marker).Error
if err == nil && marker.Value == singboxMigrationVersion {
return nil // already migrated: zero changes (idempotency)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("sing-box migration: read marker: %w", err)
}
rep := &singboxMigrationReport{
Version: singboxMigrationVersion,
StartedAt: time.Now().Format(time.RFC3339),
InboundsByProtocol: map[string]int{},
Dispositions: map[string]int{},
}
if IsPostgres() {
rep.Database = "postgres"
} else {
rep.Database = "sqlite"
}
var inboundCount, agentCount int64
if err := db.Model(&model.Inbound{}).Count(&inboundCount).Error; err != nil {
return fmt.Errorf("sing-box migration: count inbounds: %w", err)
}
if err := db.Model(&model.AgentNode{}).Count(&agentCount).Error; err != nil {
return fmt.Errorf("sing-box migration: count agent nodes: %w", err)
}
templatePresent := settingRowExists(xrayTemplateSettingKey)
hasLegacy := inboundCount > 0 || agentCount > 0 || templatePresent
// Backup gate: nothing to lose on an empty/fresh database; with
// legacy content a failed backup refuses the migration (and hence
// startup), leaving the database untouched.
if hasLegacy {
switch {
case IsPostgres():
rep.ReportNotes = append(rep.ReportNotes,
"PostgreSQL 数据库无文件级备份,迁移依赖单事务回滚保护")
case dbPath == "":
rep.ReportNotes = append(rep.ReportNotes,
"临时/内存 SQLite 数据库,无文件备份")
default:
dst := dbPath + ".pre-singbox-" + time.Now().Format("20060102-150405") + ".bak"
if err := BackupSQLite(dst); err != nil {
return fmt.Errorf("sing-box migration refused: pre-migration backup to %s failed: %w", dst, err)
}
rep.Backup = dst
}
}
if err := db.Transaction(func(tx *gorm.DB) error {
return convertAllForSingbox(tx, rep)
}); err != nil {
return fmt.Errorf("sing-box migration failed and was rolled back (database unchanged): %w", err)
}
log.Printf("sing-box migration v%s complete: %d inbounds (%d fragments, %d endpoints), stats from %s, backup %q",
rep.Version, rep.InboundsTotal, rep.FragmentsWritten, rep.EndpointsWritten, rep.StatsSource, rep.Backup)
if dbPath != "" {
reportPath := dbPath + ".singbox-migration-" + time.Now().Format("20060102-150405") + ".json"
data, merr := json.MarshalIndent(rep, "", " ")
if merr == nil {
if werr := os.WriteFile(reportPath, data, 0o644); werr != nil {
log.Printf("sing-box migration: write report %s: %v (migration itself already committed)", reportPath, werr)
}
}
}
return nil
}
func settingRowExists(key string) bool {
var n int64
if err := db.Model(&model.Setting{}).Where("key = ?", key).Count(&n).Error; err != nil {
return false
}
return n > 0
}
func upsertSetting(tx *gorm.DB, key, value string) error {
var s model.Setting
err := tx.Where("key = ?", key).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&model.Setting{Key: key, Value: value}).Error
}
if err != nil {
return err
}
if s.Value == value {
return nil
}
s.Value = value
return tx.Save(&s).Error
}
// convertAllForSingbox performs the whole conversion inside tx.
func convertAllForSingbox(tx *gorm.DB, rep *singboxMigrationReport) error {
// -- Step 1 (§4.3.1 order): stats switches OUT of the template first.
stats, source, tplPresent, nonDefault, diffOK, err := migrateTemplateStats(tx)
if err != nil {
return err
}
rep.Stats, rep.StatsSource, rep.TemplatePresent = stats, source, tplPresent
rep.TemplateNonDefault, rep.TemplateDiffAvailable = nonDefault, diffOK
statsJSON, err := json.Marshal(stats)
if err != nil {
return err
}
if err := upsertSetting(tx, trafficStatsSwitchesKey, string(statsJSON)); err != nil {
return fmt.Errorf("write %s: %w", trafficStatsSwitchesKey, err)
}
// -- Step 2 (§4.3.2): only now is the template row discarded.
if tplPresent {
if err := tx.Where("key = ?", xrayTemplateSettingKey).Delete(&model.Setting{}).Error; err != nil {
return fmt.Errorf("discard %s row: %w", xrayTemplateSettingKey, err)
}
}
// -- Step 3: inbounds.
var inbounds []model.Inbound
if err := tx.Order("id ASC").Find(&inbounds).Error; err != nil {
return fmt.Errorf("load inbounds: %w", err)
}
rep.InboundsTotal = len(inbounds)
for i := range inbounds {
ib := &inbounds[i]
rep.InboundsByProtocol[string(ib.Protocol)]++
if s := strings.TrimSpace(ib.Forwarding); s != "" && s != "{}" && s != "null" {
rep.ForwardingInbounds++
}
if err := convertOneInbound(tx, ib, rep); err != nil {
return fmt.Errorf("inbound %d (%s): %w", ib.Id, ib.Tag, err)
}
}
// -- Step 4: agent overrides.
var agents []model.AgentNode
if err := tx.Order("id ASC").Find(&agents).Error; err != nil {
return fmt.Errorf("load agent nodes: %w", err)
}
for i := range agents {
an := &agents[i]
entry := migrationAgentEntry{NodeID: an.Id, Name: an.Name}
changed := false
if strings.TrimSpace(an.Outbounds) != "" {
converted, issues, err := singbox.ConvertAgentOutbounds(an.Outbounds)
if err != nil {
return fmt.Errorf("agent node %d outbounds: %w", an.Id, err)
}
an.SingboxOutbounds = converted
entry.OutboundIssues = issues
changed = true
}
if strings.TrimSpace(an.RoutingRules) != "" {
converted, issues, err := singbox.ConvertAgentRoutingRules(an.RoutingRules)
if err != nil {
return fmt.Errorf("agent node %d routing rules: %w", an.Id, err)
}
an.SingboxRoutingRules = converted
entry.RoutingIssues = issues
changed = true
}
if changed {
if err := tx.Model(&model.AgentNode{}).Where("id = ?", an.Id).Updates(map[string]any{
"singbox_outbounds": an.SingboxOutbounds, "singbox_routing_rules": an.SingboxRoutingRules,
}).Error; err != nil {
return fmt.Errorf("agent node %d: %w", an.Id, err)
}
rep.AgentNodes = append(rep.AgentNodes, entry)
}
}
// Informational only: traditional nodes are untouched by B1 (their
// conversion rides the B6 agent conversion).
if err := tx.Model(&model.Node{}).Count(&rep.TraditionalNodes).Error; err != nil {
rep.ReportNotes = append(rep.ReportNotes, "统计传统 nodes 失败: "+err.Error())
}
// -- Step 5: marker LAST.
if err := upsertSetting(tx, singboxMigrationVersionKey, singboxMigrationVersion); err != nil {
return fmt.Errorf("write migration marker: %w", err)
}
return nil
}
// migrateTemplateStats reads the stored template (if any), extracts the
// four policy.system stats switches, and diffs the stored template
// against the factory default (excluding the four migrated keys) so the
// report can name the non-default sections being discarded.
func migrateTemplateStats(tx *gorm.DB) (stats trafficStatsSwitches, source string, present bool, nonDefault []string, diffOK bool, err error) {
stats = defaultTrafficStatsSwitches()
source = "factory-default"
var row model.Setting
ferr := tx.Where("key = ?", xrayTemplateSettingKey).First(&row).Error
if errors.Is(ferr, gorm.ErrRecordNotFound) {
return stats, source, false, nil, FactoryXrayTemplate != "", nil
}
if ferr != nil {
return stats, source, false, nil, false, ferr
}
present = true
var tpl map[string]any
if jerr := json.Unmarshal([]byte(row.Value), &tpl); jerr != nil {
// A stored template that does not parse is a hard failure: its
// stats switches cannot be extracted, and silently falling back
// to factory defaults could flip whole-network accounting.
return stats, source, true, nil, false,
fmt.Errorf("stored %s is not valid JSON, refusing to guess its stats switches: %w", xrayTemplateSettingKey, jerr)
}
source = "stored-template"
if policy, ok := tpl["policy"].(map[string]any); ok {
if sys, ok := policy["system"].(map[string]any); ok {
if v, ok := sys["statsInboundUplink"].(bool); ok {
stats.InboundUplink = v
}
if v, ok := sys["statsInboundDownlink"].(bool); ok {
stats.InboundDownlink = v
}
if v, ok := sys["statsOutboundUplink"].(bool); ok {
stats.OutboundUplink = v
}
if v, ok := sys["statsOutboundDownlink"].(bool); ok {
stats.OutboundDownlink = v
}
}
}
if FactoryXrayTemplate != "" {
var factory map[string]any
if jerr := json.Unmarshal([]byte(FactoryXrayTemplate), &factory); jerr == nil {
nonDefault = diffTemplateSections(tpl, factory)
diffOK = true
}
}
return stats, source, present, nonDefault, diffOK, nil
}
// diffTemplateSections lists top-level template sections whose content
// differs from the factory default, ignoring the four stats switches
// inside policy.system (they are migrated, not discarded).
func diffTemplateSections(stored, factory map[string]any) []string {
strip := func(m map[string]any) map[string]any {
cp := map[string]any{}
for k, v := range m {
cp[k] = v
}
if policy, ok := cp["policy"].(map[string]any); ok {
pol := map[string]any{}
for k, v := range policy {
pol[k] = v
}
if sys, ok := pol["system"].(map[string]any); ok {
s := map[string]any{}
for k, v := range sys {
switch k {
case "statsInboundUplink", "statsInboundDownlink", "statsOutboundUplink", "statsOutboundDownlink":
default:
s[k] = v
}
}
pol["system"] = s
}
cp["policy"] = pol
}
return cp
}
s, f := strip(stored), strip(factory)
keys := map[string]bool{}
for k := range s {
keys[k] = true
}
for k := range f {
keys[k] = true
}
out := []string{}
for k := range keys {
sb, _ := json.Marshal(s[k])
fb, _ := json.Marshal(f[k])
if string(sb) != string(fb) {
out = append(out, k)
}
}
return out
}
// convertOneInbound converts a single inbound row inside tx.
func convertOneInbound(tx *gorm.DB, ib *model.Inbound, rep *singboxMigrationReport) error {
clients, projected, err := resolveInboundClients(tx, ib, rep)
if err != nil {
return err
}
fallbacks, err := resolveInboundFallbacks(tx, ib)
if err != nil {
return err
}
res, err := singbox.ConvertInbound(singbox.Inbound{
Listen: ib.Listen,
Port: ib.Port,
Protocol: string(ib.Protocol),
Tag: ib.Tag,
DisableFlow: ib.DisableFlow,
Settings: json.RawMessage(ib.Settings),
StreamSettings: json.RawMessage(ib.StreamSettings),
Sniffing: json.RawMessage(ib.Sniffing),
Fallbacks: fallbacks,
Clients: clients,
})
if err != nil {
return err
}
fragJSON, err := res.MarshalFragment()
if err != nil {
return err
}
presJSON, err := res.MarshalPreserved()
if err != nil {
return err
}
if err := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Updates(map[string]any{
"singbox_config": fragJSON, "singbox_preserved": presJSON,
}).Error; err != nil {
return err
}
rep.Dispositions[res.Disposition]++
rep.ClientsProjected += projected
if res.Fragment != nil {
if res.Endpoint {
rep.EndpointsWritten++
} else {
rep.FragmentsWritten++
}
}
for _, u := range res.Unmapped {
rep.Unmapped = append(rep.Unmapped, migrationUnmappedEntry{
InboundID: ib.Id, Tag: ib.Tag, Protocol: string(ib.Protocol),
Concept: u.Concept, Pending: u.Pending, Material: u.Material,
})
}
for _, n := range res.Notes {
rep.Notes = append(rep.Notes, migrationNoteEntry{InboundID: ib.Id, Tag: ib.Tag, Text: n})
}
return nil
}
// resolveInboundClients implements spec §7-1: the identity layer
// (clients + client_inbounds, ListForInbound semantics) is the truth;
// the settings.clients mirror only fills gaps — whole-client adoption
// when an identity row is missing entirely, per-field fill when a field
// is empty — and every divergence is recorded.
func resolveInboundClients(tx *gorm.DB, ib *model.Inbound, rep *singboxMigrationReport) ([]singbox.Client, int, error) {
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", ib.Id).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, 0, fmt.Errorf("identity clients: %w", err)
}
// Mirror (settings.clients) parsed twice: as model.Client for the
// credential fields, and raw for alterId (no identity column).
mirrorByEmail := map[string]*model.Client{}
mirrorAlterID := map[string]int{}
mirrorOrder := []string{}
if strings.TrimSpace(ib.Settings) != "" {
var wrap struct {
Clients []model.Client `json:"clients"`
}
if jerr := json.Unmarshal([]byte(ib.Settings), &wrap); jerr != nil {
return nil, 0, fmt.Errorf("settings.clients mirror is not valid JSON: %w", jerr)
}
for i := range wrap.Clients {
key := strings.ToLower(wrap.Clients[i].Email)
if key == "" {
continue
}
if _, dup := mirrorByEmail[key]; !dup {
mirrorOrder = append(mirrorOrder, key)
}
mirrorByEmail[key] = &wrap.Clients[i]
}
var rawWrap struct {
Clients []map[string]any `json:"clients"`
}
if jerr := json.Unmarshal([]byte(ib.Settings), &rawWrap); jerr == nil {
for _, rc := range rawWrap.Clients {
email, _ := rc["email"].(string)
if email == "" {
continue
}
if v, ok := rc["alterId"].(float64); ok {
mirrorAlterID[strings.ToLower(email)] = int(v)
}
}
}
}
out := []singbox.Client{}
seenIdentity := map[string]bool{}
for i := range rows {
rec := &rows[i]
cl := rec.ToClient()
// Effective flow: the link's flow_override is what generation
// uses (ListForInbound overwrites unconditionally). Fall back
// to the identity record's own flow, then the mirror, when the
// override is empty — all three agree in maintained data.
effectiveFlow := rec.FlowOverride
flowSource := "flow_override"
if effectiveFlow == "" && rec.Flow != "" {
effectiveFlow = rec.Flow
flowSource = "identity"
}
cl.Flow = effectiveFlow
key := strings.ToLower(cl.Email)
seenIdentity[key] = true
mc := mirrorByEmail[key]
if mc != nil {
if flowSource != "flow_override" && mc.Flow != "" && effectiveFlow == "" {
cl.Flow = mc.Flow
flowSource = "mirror"
}
if mc.Flow != "" && effectiveFlow != "" && mc.Flow != effectiveFlow {
rep.Discrepancies = append(rep.Discrepancies, migrationDiscrepancy{
InboundID: ib.Id, Tag: ib.Tag, Email: cl.Email, Kind: "flow-source",
Field: "flow", Identity: effectiveFlow + " (" + flowSource + ")", Mirror: mc.Flow,
})
}
fill := func(field string, dst *string, src string) {
if *dst == "" && src != "" {
*dst = src
rep.Discrepancies = append(rep.Discrepancies, migrationDiscrepancy{
InboundID: ib.Id, Tag: ib.Tag, Email: cl.Email,
Kind: "field-filled-from-mirror", Field: field, Mirror: src,
})
} else if *dst != "" && src != "" && *dst != src {
rep.Discrepancies = append(rep.Discrepancies, migrationDiscrepancy{
InboundID: ib.Id, Tag: ib.Tag, Email: cl.Email,
Kind: "credential-conflict", Field: field, Identity: *dst, Mirror: src,
})
}
}
fill("uuid", &cl.ID, mc.ID)
fill("password", &cl.Password, mc.Password)
fill("auth", &cl.Auth, mc.Auth)
fill("security", &cl.Security, mc.Security)
// Per-inbound WireGuard AllowedIPs live in the mirror.
if len(mc.AllowedIPs) > 0 {
if len(cl.AllowedIPs) > 0 && strings.Join(cl.AllowedIPs, ",") != strings.Join(mc.AllowedIPs, ",") {
rep.Discrepancies = append(rep.Discrepancies, migrationDiscrepancy{
InboundID: ib.Id, Tag: ib.Tag, Email: cl.Email, Kind: "wg-allowed-ips",
Field: "allowedIPs",
Identity: strings.Join(cl.AllowedIPs, ","), Mirror: strings.Join(mc.AllowedIPs, ","),
})
}
cl.AllowedIPs = mc.AllowedIPs
}
if cl.PublicKey == "" && mc.PublicKey != "" {
cl.PublicKey = mc.PublicKey
}
if cl.PreSharedKey == "" && mc.PreSharedKey != "" {
cl.PreSharedKey = mc.PreSharedKey
}
if cl.KeepAlive == 0 && mc.KeepAlive != 0 {
cl.KeepAlive = mc.KeepAlive
}
}
out = append(out, toSingboxClient(cl, mirrorAlterID[key]))
}
// Whole-client adoption for mirror entries with no identity row.
for _, key := range mirrorOrder {
if seenIdentity[key] {
continue
}
mc := mirrorByEmail[key]
rep.Discrepancies = append(rep.Discrepancies, migrationDiscrepancy{
InboundID: ib.Id, Tag: ib.Tag, Email: mc.Email, Kind: "mirror-only-client",
})
out = append(out, toSingboxClient(mc, mirrorAlterID[key]))
}
return out, len(out), nil
}
func toSingboxClient(cl *model.Client, alterID int) singbox.Client {
sc := singbox.Client{
Email: cl.Email,
UUID: cl.ID,
Password: cl.Password,
Auth: cl.Auth,
Flow: cl.Flow,
Security: cl.Security,
AlterID: alterID,
PublicKey: cl.PublicKey,
PreSharedKey: cl.PreSharedKey,
AllowedIPs: cl.AllowedIPs,
KeepAlive: cl.KeepAlive,
Secret: cl.Secret,
AdTag: cl.AdTag,
}
if cl.Reverse != nil {
if b, err := json.Marshal(cl.Reverse); err == nil {
sc.Reverse = b
}
}
return sc
}
// resolveInboundFallbacks merges the inbound_fallbacks table (source of
// truth) with any legacy settings-embedded entries, deduplicated by
// (dest, alpn, path) per spec §7-2. Table entries are shaped like the
// stored form; a table row with an empty dest inherits the child
// inbound's listen:port (wildcard listens become 127.0.0.1), matching
// BuildFallbacksJSON's resolution.
func resolveInboundFallbacks(tx *gorm.DB, ib *model.Inbound) ([]json.RawMessage, error) {
out := []json.RawMessage{}
seen := map[string]bool{}
keyOf := func(dest any, alpn, path string) string {
return fmt.Sprintf("%v|%s|%s", dest, alpn, path)
}
var rows []model.InboundFallback
if err := tx.Where("master_id = ?", ib.Id).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return nil, fmt.Errorf("fallback rows: %w", err)
}
for _, r := range rows {
destStr := strings.TrimSpace(r.Dest)
var destVal any
switch {
case destStr == "":
var child model.Inbound
if err := tx.First(&child, r.ChildId).Error; err != nil {
return nil, fmt.Errorf("fallback child %d: %w", r.ChildId, err)
}
listen := strings.TrimSpace(child.Listen)
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
listen = "127.0.0.1"
}
destVal = fmt.Sprintf("%s:%d", listen, child.Port)
default:
if n, aerr := strconv.Atoi(destStr); aerr == nil {
destVal = n
} else {
destVal = destStr
}
}
k := keyOf(destVal, r.Alpn, r.Path)
if seen[k] {
continue
}
seen[k] = true
entry := map[string]any{
"name": r.Name, "alpn": r.Alpn, "path": r.Path, "dest": destVal, "xver": r.Xver,
}
b, err := json.Marshal(entry)
if err != nil {
return nil, err
}
out = append(out, b)
}
// Legacy embedded entries (verbatim), skipping table duplicates.
if strings.TrimSpace(ib.Settings) != "" {
var wrap struct {
Fallbacks []json.RawMessage `json:"fallbacks"`
}
if err := json.Unmarshal([]byte(ib.Settings), &wrap); err == nil {
for _, raw := range wrap.Fallbacks {
var probe struct {
Dest any `json:"dest"`
Alpn string `json:"alpn"`
Path string `json:"path"`
}
_ = json.Unmarshal(raw, &probe)
k := keyOf(probe.Dest, probe.Alpn, probe.Path)
if seen[k] {
continue
}
seen[k] = true
out = append(out, raw)
}
}
}
return out, nil
}