From e0d2a0c58c18e77c0dd40fb48f927bf1732058cb Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 23 Oct 2026 22:30:09 -0700 Subject: [PATCH] fix(fallback): derive a linked fallback's PROXY protocol header from its child (#6880) Turning on Proxy Protocol for a local fallback target behind a VLESS/Trojan TLS master broke the fallback: the row was saved with xver 0, so the master sent no PROXY header and the child, now expecting one, rejected every connection (#6835). A fallback without an explicit dest already takes its address from the child at build time; it now also sends header v1 when the child accepts PROXY protocol (sockopt or the raw/ws/httpupgrade transport flag) and the row sets no xver of its own, so either order of enabling it works, from the panel or the API. Editing a child's listen, port or PROXY flag rebuilds the masters that derive from it, since re-adding only the child left them stale. Fixes #6835. --- internal/web/service/fallback.go | 52 +++++++++++- internal/web/service/inbound.go | 11 +++ .../service/inbound_fallback_runtime_test.go | 79 +++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/internal/web/service/fallback.go b/internal/web/service/fallback.go index 56812a580..01e929b1e 100644 --- a/internal/web/service/fallback.go +++ b/internal/web/service/fallback.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "errors" "fmt" "strings" @@ -123,6 +124,7 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s out := make([]map[string]any, 0, len(rows)) for _, r := range rows { dest := strings.TrimSpace(r.Dest) + xver := r.Xver if dest == "" { child, ok := byId[r.ChildId] if !ok { @@ -133,6 +135,11 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s listen = "127.0.0.1" } dest = fmt.Sprintf("%s:%d", listen, child.Port) + // A child that accepts PROXY protocol rejects a fallback without the header (#6835); + // deriving it here keeps the row right through either order of toggling. + if xver == 0 && acceptsProxyProtocol(child.StreamSettings) { + xver = 1 + } } entry := map[string]any{ "dest": dest, @@ -146,10 +153,51 @@ func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[s if r.Path != "" { entry["path"] = r.Path } - if r.Xver > 0 { - entry["xver"] = r.Xver + if xver > 0 { + entry["xver"] = xver } out = append(out, entry) } return out, nil } + +// HasDerivedFallbackTo reports whether a master's fallback takes its dest and xver from childId. +func (s *FallbackService) HasDerivedFallbackTo(tx *gorm.DB, childId int) (bool, error) { + var n int64 + err := tx.Model(&model.InboundFallback{}).Where("child_id = ? AND TRIM(dest) = ''", childId).Count(&n).Error + return n > 0, err +} + +// fallbackTargetChanged reports whether an edit changed what a derived fallback is built from. +func fallbackTargetChanged(before, after *model.Inbound) bool { + return before.Listen != after.Listen || before.Port != after.Port || + acceptsProxyProtocol(before.StreamSettings) != acceptsProxyProtocol(after.StreamSettings) +} + +// proxyProtocolTransportKey maps the transports whose settings carry acceptProxyProtocol; +// RAW is stored as tcp, older rows say raw. +var proxyProtocolTransportKey = map[string]string{ + "tcp": "tcpSettings", + "raw": "tcpSettings", + "ws": "wsSettings", + "httpupgrade": "httpupgradeSettings", +} + +// acceptsProxyProtocol reports whether an inbound's listener expects a PROXY header, via +// sockopt or its transport, the two places xray-core wraps the listener. +func acceptsProxyProtocol(streamSettings string) bool { + var stream map[string]any + if json.Unmarshal([]byte(streamSettings), &stream) != nil { + return false + } + if sockopt, _ := stream["sockopt"].(map[string]any); sockopt["acceptProxyProtocol"] == true { + return true + } + network, _ := stream["network"].(string) + key, ok := proxyProtocolTransportKey[strings.ToLower(network)] + if !ok { + return false + } + transport, _ := stream[key].(map[string]any) + return transport["acceptProxyProtocol"] == true +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index f82a260a0..872a41669 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -2267,6 +2267,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := tx.Save(oldInbound).Error; err != nil { return err } + // A master's running fallback was built from this child's listen, port and PROXY flag; + // re-adding only the child would leave the master pointing at the old ones. + if fallbackTargetChanged(&running, oldInbound) { + linked, err := s.fallbackService.HasDerivedFallbackTo(tx, oldInbound.Id) + if err != nil { + return err + } + if linked { + needRestart = true + } + } newClients, gcErr := s.GetClients(oldInbound) if gcErr != nil { return gcErr diff --git a/internal/web/service/inbound_fallback_runtime_test.go b/internal/web/service/inbound_fallback_runtime_test.go index 5ad2e733e..a607f7fb0 100644 --- a/internal/web/service/inbound_fallback_runtime_test.go +++ b/internal/web/service/inbound_fallback_runtime_test.go @@ -76,3 +76,82 @@ func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) { t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings) } } + +// #6835: a child that accepts PROXY protocol must get a header from its fallback, whichever +// was saved first; an explicit dest or a chosen xver stays the operator's. +func TestBuildFallbacksJSON_DerivesXverFromTheChild(t *testing.T) { + setupBulkDB(t) + db := database.GetDB() + master := mkInbound(t, 30301, model.VLESS, `{"clients":[]}`) + + cases := []struct { + name string + stream string + dest string + xver int + want any + }{ + {"raw transport flag", `{"network":"tcp","tcpSettings":{"acceptProxyProtocol":true}}`, "", 0, float64(1)}, + {"sockopt flag", `{"network":"grpc","sockopt":{"acceptProxyProtocol":true}}`, "", 0, float64(1)}, + {"websocket flag", `{"network":"ws","wsSettings":{"acceptProxyProtocol":true}}`, "", 0, float64(1)}, + {"stale flag of another transport", `{"network":"grpc","tcpSettings":{"acceptProxyProtocol":true}}`, "", 0, nil}, + {"child without PROXY protocol", `{"network":"tcp"}`, "", 0, nil}, + {"explicit dest keeps the stored xver", `{"network":"tcp","tcpSettings":{"acceptProxyProtocol":true}}`, "127.0.0.1:9000", 0, nil}, + {"chosen xver 2 stays", `{"network":"tcp","tcpSettings":{"acceptProxyProtocol":true}}`, "", 2, float64(2)}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := db.Where("master_id = ?", master.Id).Delete(&model.InboundFallback{}).Error; err != nil { + t.Fatal(err) + } + child := mkInboundStream(t, 30310+i, model.VLESS, `{"clients":[]}`, tc.stream) + fb := model.InboundFallback{MasterId: master.Id, ChildId: child.Id, Dest: tc.dest, Xver: tc.xver} + if err := db.Create(&fb).Error; err != nil { + t.Fatal(err) + } + got, err := (&FallbackService{}).BuildFallbacksJSON(db, master.Id) + if err != nil || len(got) != 1 { + t.Fatalf("BuildFallbacksJSON = %v, %v", got, err) + } + raw, _ := json.Marshal(got[0]) + var entry map[string]any + _ = json.Unmarshal(raw, &entry) + if entry["xver"] != tc.want { + t.Fatalf("xver = %v, want %v (entry %s)", entry["xver"], tc.want, raw) + } + }) + } +} + +// The running master keeps the fallback it was built with, so turning PROXY protocol on in a +// linked child must rebuild the master too; an inbound no fallback derives from needs nothing. +func TestUpdateInbound_FallbackChildChangeRebuildsItsMaster(t *testing.T) { + setupBulkDB(t) + mgr := useTestRuntimeManager(t) + mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{}) + db := database.GetDB() + master := mkInboundStream(t, 30401, model.VLESS, `{"clients":[]}`, `{"network":"tcp","security":"tls"}`) + linked := mkInboundStream(t, 30402, model.VLESS, `{"clients":[]}`, `{"network":"tcp"}`) + unlinked := mkInboundStream(t, 30403, model.VLESS, `{"clients":[]}`, `{"network":"tcp"}`) + if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: linked.Id}).Error; err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + ib *model.Inbound + want bool + }{{"linked child", linked, true}, {"unlinked inbound", unlinked, false}} { + t.Run(tc.name, func(t *testing.T) { + update := *tc.ib + update.StreamSettings = `{"network":"tcp","tcpSettings":{"acceptProxyProtocol":true}}` + _, needRestart, err := (&InboundService{}).UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + if needRestart != tc.want { + t.Fatalf("needRestart = %v, want %v", needRestart, tc.want) + } + }) + } +}