Files
3x-ui-agent/internal/database/singbox_migrate.go
T
Muse 39ad8b8a2e ITEM6 B1: sing-box data model + one-shot migration
New stored model (legacy columns untouched, read paths unchanged
until B2):
- inbounds.singbox_config (converted sing-box inbound/endpoint
  fragment JSON; empty = blocked or external-runtime) and
  inbounds.singbox_preserved (unmapped items verbatim + panel-side
  material + notes)
- agent_nodes.singbox_outbounds / singbox_routing_rules

Converter (internal/singbox, pure functions, ITEM6-CONVERSION-SPEC):
all panel protocols mapped; unmapped constructs are preserved and
registered, never dropped. B0 golden fixtures: 26/26 semantic match
(fragment + unmapped concept/pending sets + disposition).

One-shot migrator (internal/database, InitDB hook after initModels):
marker-gated and idempotent; SQLite file backup before any write
(failure refuses migration/startup); stats switches migrate out of
the template BEFORE the xrayTemplateConfig row is discarded
(SCOPE 4.3.1 order); identity layer is the only client truth with
spec 7-1 mirror reconciliation (adopt/fill/conflict all reported);
fallbacks merged from table + embedded settings, deduped; agent
overrides converted with per-entry issues; everything in one
transaction, marker written last; JSON report written next to the
DB. Malformed rows/templates are hard failures (rollback, old DB
untouched).

settings.trafficStatsSwitches added with factory defaults
(inbound on, outbound off) in defaultValueMap.

Validation: synthetic agent-era DB (mirror drift, fallbacks, xhttp,
agent geo rule, non-default stats) converts as specified; old
production backup (25 inbounds) migrates with credentials, Reality
pbk derivation, traffic counters and reset cycles byte-identical;
idempotent re-run is a no-op; touched packages show zero new test
failures vs the 9-red baseline.
2026-10-22 19:32:15 +00:00

728 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),
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
}