From 39eb5baf420bc1ceb1bd03c5f4b3d020cb7a56de Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 13:50:06 +0200 Subject: [PATCH] fix(inbound): convert legacy externalProxy to hosts on import An inbound exported from a build that predated the hosts table carries its external proxies inline in streamSettings.externalProxy. The startup migration that converts those to host rows runs once and is gated off afterwards, so it never sees a freshly imported inbound, leaving its external proxies stranded in streamSettings (never surfaced as Hosts). Extract the migration's per-inbound conversion into a shared database.CreateHostsFromExternalProxy and run it inside the AddInbound transaction. No-op for inbounds without externalProxy (everything the current UI builds), so it only fires on such imports. --- internal/database/db.go | 56 ++++++---- internal/web/service/inbound.go | 10 ++ .../inbound_import_external_proxy_test.go | 103 ++++++++++++++++++ 3 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 internal/web/service/inbound_import_external_proxy_test.go diff --git a/internal/database/db.go b/internal/database/db.go index ef21409e2..92b6b405c 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -183,32 +183,48 @@ func seedHostsFromExternalProxy() error { return db.Transaction(func(tx *gorm.DB) error { for _, inbound := range inbounds { - if strings.TrimSpace(inbound.StreamSettings) == "" { - continue - } - var stream map[string]any - if err := json.Unmarshal([]byte(inbound.StreamSettings), &stream); err != nil { - log.Printf("HostsFromExternalProxy: skip inbound %d (invalid stream json): %v", inbound.Id, err) - continue - } - eps, ok := stream["externalProxy"].([]any) - if !ok || len(eps) == 0 { - continue - } - for i, raw := range eps { - ep, ok := raw.(map[string]any) - if !ok { - continue - } - if err := tx.Create(externalProxyEntryToHost(inbound.Id, i, ep)).Error; err != nil { - return err - } + if _, err := CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil { + return err } } return tx.Create(&model.HistoryOfSeeders{SeederName: "HostsFromExternalProxy"}).Error }) } +// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array +// and inserts one Host row per entry on tx, returning the number of rows created. +// It is the shared core of both the one-time seedHostsFromExternalProxy startup +// migration and the inbound-import path: an inbound exported from a build that +// predated the hosts table carries its external proxies inline in +// streamSettings.externalProxy, and the startup migration is gated off after its +// first run, so a freshly imported inbound must be converted here instead. Blank +// or malformed streamSettings, or one without externalProxy entries, is a no-op. +func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) { + if strings.TrimSpace(streamSettings) == "" { + return 0, nil + } + var stream map[string]any + if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { + return 0, nil + } + eps, ok := stream["externalProxy"].([]any) + if !ok || len(eps) == 0 { + return 0, nil + } + created := 0 + for i, raw := range eps { + ep, ok := raw.(map[string]any) + if !ok { + continue + } + if err := tx.Create(externalProxyEntryToHost(inboundId, i, ep)).Error; err != nil { + return created, err + } + created++ + } + return created, nil +} + // externalProxyEntryToHost maps one legacy externalProxy entry onto a Host. // forceTls (same|tls|none) maps straight to Security; an unknown value falls back // to "same" (inherit). An empty remark gets a stable generated label so the row diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 286ae4ebd..44ba5d2c8 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -705,6 +705,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, err } + // Legacy import: an inbound exported from a build that predated the hosts + // table carries its external proxies inline in streamSettings.externalProxy. + // The startup migration that converts those to host rows runs once and is + // gated off afterwards, so it never sees a freshly imported inbound — + // reproduce it here. No-op for inbounds without externalProxy (everything the + // current UI builds), so this only fires on such imports. + if _, err = database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil { + return inbound, false, err + } + // Before the deferred commit, so a node in "selected" sync mode cannot // sweep the new central row in the gap before its tag is allowed. if inbound.NodeID != nil { diff --git a/internal/web/service/inbound_import_external_proxy_test.go b/internal/web/service/inbound_import_external_proxy_test.go new file mode 100644 index 000000000..96553b59b --- /dev/null +++ b/internal/web/service/inbound_import_external_proxy_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// TestAddInbound_ImportConvertsExternalProxyToHosts reproduces the panel report: +// an inbound exported from a build that predated the hosts table carries its +// external proxies inline in streamSettings.externalProxy. The one-time startup +// migration that converts those to host rows is gated off after first run, so a +// freshly imported inbound used to land with zero hosts (its external proxies +// silently lost). AddInbound must convert them on import. +func TestAddInbound_ImportConvertsExternalProxyToHosts(t *testing.T) { + setupConflictDB(t) + svc := &InboundService{} + + stream := `{ + "network":"ws", + "wsSettings":{"path":"/req3","host":"astr.khafanha.ir"}, + "security":"none", + "externalProxy":[ + {"forceTls":"same","dest":"snapp.ir","port":8080,"remark":"","sni":"","alpn":[],"pinnedPeerCertSha256":[],"echConfigList":""}, + {"forceTls":"tls","dest":"cdn.example.com","port":8443,"remark":"front","sni":"sni.example.com","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"} + ] + }` + settings := `{"clients":[{"id":"6df5616b-ebfd-4186-86d5-4bce29fe8805","email":"imp_user","subId":"s-imp","enable":true}],"decryption":"none","encryption":"none"}` + + in := &model.Inbound{ + UserId: 1, + Tag: "in-8080-tcp", + Enable: true, + Listen: "", + Port: 8080, + Protocol: model.VLESS, + StreamSettings: stream, + Settings: settings, + } + created, _, err := svc.AddInbound(in) + if err != nil { + t.Fatalf("import inbound: %v", err) + } + + var hosts []model.Host + if err := database.GetDB().Where("inbound_id = ?", created.Id).Order("sort_order asc").Find(&hosts).Error; err != nil { + t.Fatalf("load hosts: %v", err) + } + if len(hosts) != 2 { + t.Fatalf("hosts = %d, want 2 (one per externalProxy entry)", len(hosts)) + } + + a := hosts[0] + if a.SortOrder != 0 || a.Security != "same" || a.Address != "snapp.ir" || a.Port != 8080 { + t.Fatalf("host A mapping wrong: %+v", a) + } + if a.Remark == "" { + t.Fatalf("host A remark must be backfilled for a blank externalProxy remark, got empty") + } + + b := hosts[1] + if b.SortOrder != 1 || b.Security != "tls" || b.Address != "cdn.example.com" || b.Port != 8443 || + b.Remark != "front" || b.Sni != "sni.example.com" || b.Fingerprint != "chrome" || b.EchConfigList != "ECHV" { + t.Fatalf("host B mapping wrong: %+v", b) + } + if len(b.Alpn) != 2 || b.Alpn[0] != "h2" || b.Alpn[1] != "h3" { + t.Fatalf("host B alpn = %v, want [h2 h3]", b.Alpn) + } + if len(b.PinnedPeerCertSha256) != 1 || b.PinnedPeerCertSha256[0] != "AAAA" { + t.Fatalf("host B pins = %v, want [AAAA]", b.PinnedPeerCertSha256) + } +} + +// TestAddInbound_NoExternalProxyCreatesNoHosts guards the no-op path: an inbound +// built by the current UI (no externalProxy) must not gain phantom host rows. +func TestAddInbound_NoExternalProxyCreatesNoHosts(t *testing.T) { + setupConflictDB(t) + svc := &InboundService{} + + in := &model.Inbound{ + UserId: 1, + Tag: "in-9201-tcp", + Enable: true, + Listen: "0.0.0.0", + Port: 9201, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"none"}`, + Settings: `{"clients":[{"id":"77777777-7777-7777-7777-777777777777","email":"plain","subId":"s-plain","enable":true}],"decryption":"none","encryption":"none"}`, + } + created, _, err := svc.AddInbound(in) + if err != nil { + t.Fatalf("add inbound: %v", err) + } + + var count int64 + if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", created.Id).Count(&count).Error; err != nil { + t.Fatalf("count hosts: %v", err) + } + if count != 0 { + t.Fatalf("host count = %d, want 0", count) + } +}