浏览代码

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.
MHSanaei 11 小时之前
父节点
当前提交
39eb5baf42
共有 3 个文件被更改,包括 149 次插入20 次删除
  1. 36 20
      internal/database/db.go
  2. 10 0
      internal/web/service/inbound.go
  3. 103 0
      internal/web/service/inbound_import_external_proxy_test.go

+ 36 - 20
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

+ 10 - 0
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 {

+ 103 - 0
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)
+	}
+}