From 43af7ada969e9f755af900100e40d12763679a24 Mon Sep 17 00:00:00 2001 From: Denis Gritsenko Date: Mon, 2 Nov 2026 17:27:49 +0300 Subject: [PATCH] fix(sub): restore TUIC links in subscriptions after v3.11.0 (#6963) The subscription settings cache strips each inbound's clients array, but the TUIC link generator still reads credentials from it, so raw subscriptions (v2rayN, Shadowrocket) lost every TUIC link while the browser page kept it. TUIC now reads its full settings, as WireGuard and AmneziaWG already do. --- internal/sub/service.go | 6 ++-- internal/sub/settings_cache_test.go | 43 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/internal/sub/service.go b/internal/sub/service.go index 4b0b23ad1..49c53cef4 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -863,15 +863,15 @@ func (s *SubService) loadInboundsBySubId(subId string) ([]*model.Inbound, error) return inbounds, nil } -// subscriptionSettings is an inbound's settings for a subscription body: WireGuard and AmneziaWG -// keep their peers, every other protocol gets the shared copy without the clients array. +// WireGuard/AmneziaWG need their peers, and TUIC link generation reads embedded clients. +// Other protocols can share cached settings without the clients array. func (s *SubService) subscriptionSettings(inbound *model.Inbound) (string, error) { load := func() (string, error) { var settings string err := database.GetDB().Model(&model.Inbound{}).Select("settings").Where("id = ?", inbound.Id).Row().Scan(&settings) return settings, err } - if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG { + if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG || inbound.Protocol == model.TUIC { return load() } return s.settingsCache.settingsFor(inbound.Id, load) diff --git a/internal/sub/settings_cache_test.go b/internal/sub/settings_cache_test.go index 57b8ee98f..92bd373f2 100644 --- a/internal/sub/settings_cache_test.go +++ b/internal/sub/settings_cache_test.go @@ -166,6 +166,49 @@ func TestSettingsCacheRendersTheSameSubscription(t *testing.T) { } } +func TestSettingsCachePreservesTuicSubscriptions(t *testing.T) { + seedSubDB(t) + ib := seedSubProtocolInbound(t, "tuic-sub", "tuic", 4704, 1, `{}`, model.TUIC) + settings := `{"server":{"sni":"tuic.example.com"},"clients":[{"uuid":"11111111-2222-4333-8444-000000004704","password":"tuic-password","email":"tuic@e","subId":"tuic-sub","enable":true}]}` + if err := database.GetDB().Model(ib).Update("settings", settings).Error; err != nil { + t.Fatal(err) + } + if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", "tuic@e").Update("password", "tuic-password").Error; err != nil { + t.Fatal(err) + } + render := func(svc *SubService) []string { + t.Helper() + req := svc.ForRequest("req.example.com") + req.subscriptionBody = true + links, _, _, _, err := req.getSubs("tuic-sub") + if err != nil { + t.Fatal(err) + } + if len(links) != 1 || !strings.HasPrefix(links[0], "tuic://") { + t.Fatalf("TUIC link missing from subscription: %v", links) + } + clash, _, err := NewSubClashService(false, "", false, svc).GetClash("tuic-sub", "req.example.com") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(clash, "tuic-password") { + t.Fatalf("TUIC credentials missing from Clash subscription: %s", clash) + } + return []string{links[0], clash} + } + want := render(NewSubService("")) + cached := NewSubService("") + t.Cleanup(cached.enableSettingsCache()) + for round := range 2 { + got := render(cached) + for i, format := range []string{"raw", "clash"} { + if got[i] != want[i] { + t.Fatalf("round %d %s differs with settings cache:\n got: %s\nwant: %s", round, format, got[i], want[i]) + } + } + } +} + // Only the settings blob is cached: the inbound row is read on every fetch, so a port change or a // disabled inbound reaches the next fetch, while settings wait for a panel edit's purge. func TestSettingsCacheReadsTheInboundRowFresh(t *testing.T) {