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.
728 lines
24 KiB
Go
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
|
|
}
|