diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index ff1d54691..46b031237 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -294,6 +294,7 @@ export default function InboundsPage() { if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound; for (const candidate of dbInbounds) { if (candidate.id === dbInbound.id) continue; + if ((candidate.nodeId ?? 0) !== (dbInbound.nodeId ?? 0)) continue; if (!['trojan', 'vless'].includes(candidate.protocol)) continue; const candStream = coerceInboundJsonField(candidate.streamSettings) as { network?: string }; if (candStream.network !== 'tcp') continue; diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index e7781e83e..3d674bbc6 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -233,6 +233,57 @@ func TestSub_HostOverFallback(t *testing.T) { } } +// The legacy "@socket" lookup stays on the child's own host: an older inbound +// mirrored from another node that names the same socket is never the master. +func TestSub_LegacyFallbackMasterStaysOnChildHost(t *testing.T) { + foreign, own := 7, 8 + for _, tc := range []struct { + name string + nodeID *int + }{ + {"local child", nil}, + {"node child", &own}, + } { + t.Run(tc.name, func(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + for _, master := range []*model.Inbound{ + { + UserId: 1, Tag: "foreign", Enable: true, Port: 44301, Protocol: model.VLESS, NodeID: &foreign, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@vless-ws"}]}`, + StreamSettings: `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"none","dest":"evil.example","port":8080}]}`, + }, + { + UserId: 1, Tag: "front", Enable: true, Port: 9443, Protocol: model.VLESS, NodeID: tc.nodeID, + Settings: `{"clients":[],"decryption":"none","fallbacks":[{"dest":"@vless-ws"}]}`, + StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"front.sni"}}`, + }, + } { + if err := db.Create(master).Error; err != nil { + t.Fatalf("seed %s: %v", master.Tag, err) + } + } + child := seedSubInbound(t, "s1", "child", 4441, 1, `{"network":"ws","security":"none"}`) + if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id). + Updates(map[string]any{"listen": "@vless-ws", "node_id": tc.nodeID}).Error; err != nil { + t.Fatalf("place child: %v", err) + } + + links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + joined := strings.Join(links, "\n") + if strings.Contains(joined, "evil.example") { + t.Fatalf("another node's inbound was used as fallback master: %s", joined) + } + if !strings.Contains(joined, ":9443") || !strings.Contains(joined, "sni=front.sni") { + t.Fatalf("the front on the child's host must be projected: %s", joined) + } + }) + } +} + // #8 — a client only gets hosts for inbounds it is actually on (the // clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds. func TestSub_HostsResolveViaClientInbounds(t *testing.T) { diff --git a/internal/sub/service.go b/internal/sub/service.go index 89cc785f0..4d6f95e5f 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -827,7 +827,7 @@ func (s *SubService) indexStatsBySubId(subId string) { // when applicable. Covers both fallback mechanisms: // - panel-tracked: an inbound_fallbacks row where child_id = inbound.Id // - legacy unix-socket: inbound.Listen begins with "@" and some VLESS/ -// Trojan inbound's settings.fallbacks references that listen address +// Trojan inbound on the same host names that socket in settings.fallbacks // // Returns true when a projection happened; sub services call this before // generating links so a child VLESS-WS bound to 127.0.0.1 emits the @@ -861,7 +861,15 @@ func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool { if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { var m model.Inbound - if err := db.Model(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 {