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