From dda44c1e72d43d2554d7f7a68080a3834b980f02 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 09:06:32 +0100 Subject: [PATCH] perf(sub): resolve legacy socket fallback fronts once per request projectThroughFallbackMaster found the front of each unix-socket child by scanning every inbound's settings JSON, once per child, so a subscription cost the number of socket inbounds times the size of all settings in the table. A request now collects every host's socket fronts in one statement, the lowest-id front naming a socket on that host still wins, and an inbound with unreadable settings no longer fails the lookup. --- internal/sub/host_sub_test.go | 66 ++++++++++++++++++++++++++++++++++ internal/sub/service.go | 68 ++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 3d674bbc6..e01ff30ea 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -17,6 +17,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "gorm.io/gorm" ) func seedSubDB(t *testing.T) { @@ -2011,3 +2013,67 @@ func TestSub_HostMultiSNI_Overrides(t *testing.T) { t.Errorf("expected blank sni: %s", parts[1]) } } + +// The "@socket" front lookup parses every inbound's settings, so a request runs it +// once, not per child; the lower-id front still wins and unreadable settings are skipped. +func TestSub_LegacyFallbackMasterLookedUpOncePerRequest(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + fronts := []*model.Inbound{ + { + UserId: 1, Tag: "front", Enable: true, Port: 9443, Protocol: model.VLESS, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":80},{"dest":"@child-1"},"not-an-object"]}`, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"front.sni"}}`, + }, + { + UserId: 1, Tag: "newer-front", Enable: true, Port: 9444, Protocol: model.VLESS, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@child-1"},{"dest":"@child-2"}]}`, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"newer.sni"}}`, + }, + {UserId: 1, Tag: "broken", Enable: true, Port: 9445, Protocol: model.VLESS, Settings: `{not json`, StreamSettings: `{}`}, + } + for _, ib := range fronts { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed %s: %v", ib.Tag, err) + } + } + for i := 1; i <= 3; i++ { + child := seedSubInbound(t, "s1", fmt.Sprintf("child-%d", i), 4450+i, i, `{"network":"ws","security":"none"}`) + child.Listen = fmt.Sprintf("@child-%d", i) + if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", child.Listen).Error; err != nil { + t.Fatalf("set child listen: %v", err) + } + } + + scans := 0 + count := func(tx *gorm.DB) { + if strings.Contains(tx.Statement.SQL.String(), "$.fallbacks") { + scans++ + } + } + _ = db.Callback().Query().After("gorm:query").Register("test:count_front_scans", count) + _ = db.Callback().Row().After("gorm:row").Register("test:count_front_scans", count) + t.Cleanup(func() { + _ = db.Callback().Query().Remove("test:count_front_scans") + _ = db.Callback().Row().Remove("test:count_front_scans") + }) + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + if scans != 1 { + t.Fatalf("inbound settings were scanned %d times in one request, want 1", scans) + } + if len(links) != 3 { + t.Fatalf("links = %d, want 3: %v", len(links), links) + } + for i, want := range []string{"req.example.com:9443?", "req.example.com:9444?", "req.example.com:4453?"} { + if !strings.Contains(links[i], want) { + t.Fatalf("child-%d link should advertise %s: %s", i+1, want, links[i]) + } + } + if !strings.Contains(links[0], "sni=front.sni") { + t.Fatalf("child-1 must carry its front's TLS state: %s", links[0]) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 0a0d3cf64..0f6ea464d 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -79,6 +79,9 @@ type SubService struct { // with the clients array left out; generators read only inbound-level // fields (encryption, method, version, …) from it. settingsByInbound map[int]map[string]any + // socketFronts maps a host and a unix-socket dest named by a settings.fallbacks to the + // lowest-id inbound of that host naming it. Filled by socketFront; reset per request. + socketFronts map[socketFrontKey]*model.Inbound // rendered is what this request has put into its response so far. rendered renderBudget // settingsCache is shared by this service's requests (ForRequest copies the pointer); only @@ -130,6 +133,7 @@ func (s *SubService) PrepareForRequest(host string) { s.clientsByInbound = map[int]map[string]model.Client{} s.fullyPrimedInbounds = map[int]bool{} s.settingsByInbound = map[int]map[string]any{} + s.socketFronts = nil s.hostsByInbound = nil s.hostGroupOrders = nil s.sharedGroupSni = nil @@ -878,21 +882,9 @@ func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool { } if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { - var m model.Inbound // A unix socket exists only on the host that binds it, so only an inbound // hosted where the child runs (this panel, or the same node) can front it. - q := db.Model(model.Inbound{}) - if inbound.NodeID != nil { - q = q.Where("node_id = ?", *inbound.NodeID) - } else { - q = q.Where("node_id IS NULL") - } - if err := q. - Where("JSON_TYPE(settings, '$.fallbacks') = 'array'"). - Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen). - First(&m).Error; err == nil { - master = &m - } + master = s.socketFront(socketFrontKey{node: nodeKeyOf(inbound.NodeID), dest: inbound.Listen}) } if master == nil { @@ -3521,3 +3513,53 @@ func extraSalamanderKeys(settings map[string]any, expressedPacketSize bool) []st sort.Strings(extra) return extra } + +// socketFrontKey names a unix socket on one host: 0 is this panel, else the node id. +type socketFrontKey struct { + node int + dest string +} + +func nodeKeyOf(nodeID *int) int { + if nodeID == nil { + return 0 + } + return *nodeID +} + +// socketFront resolves a legacy unix-socket child to its front. That means parsing the +// settings JSON of every inbound, so the scan runs once per request, not once per child. +func (s *SubService) socketFront(key socketFrontKey) *model.Inbound { + if s.socketFronts == nil { + s.socketFronts = map[socketFrontKey]*model.Inbound{} + var fronts []struct { + NodeID *int + Listen string + Port int + StreamSettings string + Fallbacks string + } + // json_valid keeps one inbound with unreadable settings from failing the + // whole statement and hiding every front. + if err := database.GetDB().Model(model.Inbound{}). + Select("node_id, listen, port, stream_settings, json_extract(settings, '$.fallbacks') AS fallbacks"). + Where("CASE WHEN json_valid(settings) THEN JSON_TYPE(settings, '$.fallbacks') END = 'array'"). + Order("id ASC").Scan(&fronts).Error; err != nil { + return nil + } + for _, front := range fronts { + var fallbacks []any + _ = json.Unmarshal([]byte(front.Fallbacks), &fallbacks) + for _, fallback := range fallbacks { + rule, _ := fallback.(map[string]any) + dest, _ := rule["dest"].(string) + k := socketFrontKey{node: nodeKeyOf(front.NodeID), dest: dest} + if _, taken := s.socketFronts[k]; taken || !strings.HasPrefix(dest, "@") { + continue + } + s.socketFronts[k] = &model.Inbound{Listen: front.Listen, Port: front.Port, StreamSettings: front.StreamSettings} + } + } + } + return s.socketFronts[key] +}