From d2a0216e01fbf8993ee6d23135ea23a2f951bb1b Mon Sep 17 00:00:00 2001 From: 25f2002654 Date: Sat, 24 Oct 2026 09:20:46 +0530 Subject: [PATCH] fix(web): install the share-link provider before the panel serves (#6914) The panel started answering requests before the share-link generator was registered, so an "all links", client sub-links or Telegram link request that arrived during start-up got "sub link provider not registered". NewServer now takes the provider and installs it before anything listens, and the provider registry is guarded by a lock, since start-up writes it while requests read it. --- internal/web/node_contract_test.go | 2 +- internal/web/routes_contract_test.go | 2 +- internal/web/service/inbound_sublink.go | 31 ++++++++++--- internal/web/service/sublink_registry_test.go | 44 +++++++++++++++++++ internal/web/web.go | 15 ++++--- internal/web/web_shutdown_test.go | 4 +- internal/web/web_sublink_test.go | 40 +++++++++++++++++ internal/web/web_tuic_shutdown_test.go | 2 +- main.go | 5 +-- 9 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 internal/web/service/sublink_registry_test.go create mode 100644 internal/web/web_sublink_test.go diff --git a/internal/web/node_contract_test.go b/internal/web/node_contract_test.go index 072337f1a..4b1bd9f9e 100644 --- a/internal/web/node_contract_test.go +++ b/internal/web/node_contract_test.go @@ -43,7 +43,7 @@ func startContractNode(t *testing.T) *nodeUnderContract { t.Cleanup(func() { runtime.SetManager(prevMgr) }) previous := global.GetWebServer() - s := NewServer() + s := NewServer(nil) s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds()) global.SetWebServer(s) t.Cleanup(func() { diff --git a/internal/web/routes_contract_test.go b/internal/web/routes_contract_test.go index 0364c25a5..dccdcb42e 100644 --- a/internal/web/routes_contract_test.go +++ b/internal/web/routes_contract_test.go @@ -47,7 +47,7 @@ func registeredContractRoutes(t *testing.T) map[string]bool { dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) previous := global.GetWebServer() - s := NewServer() + s := NewServer(nil) s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds()) global.SetWebServer(s) t.Cleanup(func() { diff --git a/internal/web/service/inbound_sublink.go b/internal/web/service/inbound_sublink.go index 7fe8b886a..815bfd928 100644 --- a/internal/web/service/inbound_sublink.go +++ b/internal/web/service/inbound_sublink.go @@ -1,6 +1,8 @@ package service import ( + "sync" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/common" ) @@ -11,35 +13,50 @@ type SubLinkProvider interface { LinksForInbounds(host string, inbounds []*model.Inbound) []string } -var registeredSubLinkProvider SubLinkProvider +var ( + subLinkProviderMu sync.RWMutex + registeredSubLinkProvider SubLinkProvider +) func RegisterSubLinkProvider(p SubLinkProvider) { + subLinkProviderMu.Lock() + defer subLinkProviderMu.Unlock() registeredSubLinkProvider = p } +// subLinkProvider guards the registry: startup writes it while link requests read it. +func subLinkProvider() SubLinkProvider { + subLinkProviderMu.RLock() + defer subLinkProviderMu.RUnlock() + return registeredSubLinkProvider +} + func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) { - if registeredSubLinkProvider == nil { + provider := subLinkProvider() + if provider == nil { return nil, common.NewError("sub link provider not registered") } - return registeredSubLinkProvider.SubLinksForSubId(host, subId) + return provider.SubLinksForSubId(host, subId) } func (s *InboundService) GetAllInboundLinks(host string, userId int) ([]string, error) { - if registeredSubLinkProvider == nil { + provider := subLinkProvider() + if provider == nil { return nil, common.NewError("sub link provider not registered") } inbounds, err := s.GetInbounds(userId) if err != nil { return nil, err } - return registeredSubLinkProvider.LinksForInbounds(host, inbounds), nil + return provider.LinksForInbounds(host, inbounds), nil } func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) { if email == "" { return nil, common.NewError("client email is required") } - if registeredSubLinkProvider == nil { + provider := subLinkProvider() + if provider == nil { return nil, common.NewError("sub link provider not registered") } rec, err := s.clientService.GetRecordByEmail(nil, email) @@ -56,7 +73,7 @@ func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, if getErr != nil { return nil, getErr } - links = append(links, registeredSubLinkProvider.LinksForClient(host, inbound, email)...) + links = append(links, provider.LinksForClient(host, inbound, email)...) } return links, nil } diff --git a/internal/web/service/sublink_registry_test.go b/internal/web/service/sublink_registry_test.go new file mode 100644 index 000000000..81e70ac55 --- /dev/null +++ b/internal/web/service/sublink_registry_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "sync" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +type fakeSubLinkProvider struct { + links []string +} + +func (f fakeSubLinkProvider) SubLinksForSubId(host, subId string) ([]string, error) { + return f.links, nil +} + +func (f fakeSubLinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string { + return f.links +} + +func (f fakeSubLinkProvider) LinksForInbounds(host string, inbounds []*model.Inbound) []string { + return f.links +} + +// Startup writes the provider registry while link requests read it; under -race an +// unguarded registry fails here. +func TestSubLinkProviderRegistrationIsRaceFree(t *testing.T) { + t.Cleanup(func() { RegisterSubLinkProvider(nil) }) + + var wg sync.WaitGroup + for range 8 { + wg.Add(2) + go func() { + defer wg.Done() + RegisterSubLinkProvider(fakeSubLinkProvider{links: []string{"vless://example"}}) + }() + go func() { + defer wg.Done() + _, _ = (&InboundService{}).GetSubLinks("panel.example", "subid") + }() + } + wg.Wait() +} diff --git a/internal/web/web.go b/internal/web/web.go index ecc41c26a..45fe54bf4 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -138,8 +138,12 @@ type Server struct { cancel context.CancelFunc } -// NewServer creates a new web server instance with a cancellable context. -func NewServer() *Server { +// NewServer installs the share-link provider before anything serves, so an early link request +// no longer gets "sub link provider not registered". +func NewServer(subLinkProvider service.SubLinkProvider) *Server { + if subLinkProvider != nil { + service.RegisterSubLinkProvider(subLinkProvider) + } ctx, cancel := context.WithCancel(context.Background()) return &Server{ ctx: ctx, @@ -550,11 +554,12 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { // add/update/delete to either the local xray or a remote node panel. // The closures bridge into XrayService (which owns the running xray // process state) without forcing the runtime package to import service. - runtime.SetManager(runtime.NewManager(runtime.LocalDeps{ + mgr := runtime.NewManager(runtime.LocalDeps{ APIPort: func() int { return s.xrayService.GetXrayAPIPort() }, SetNeedRestart: func() { s.xrayService.SetToNeedRestart() }, - })) - runtime.GetManager().SetNodeEgressResolver(&s.settingService) + }) + runtime.SetManager(mgr) + mgr.SetNodeEgressResolver(&s.settingService) // Supply the master client certificate for nodes in mtls mode. Issued lazily // from the node CA on first use; runtime stays free of a service import. runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) { diff --git a/internal/web/web_shutdown_test.go b/internal/web/web_shutdown_test.go index 49e1ba68c..599c1cd77 100644 --- a/internal/web/web_shutdown_test.go +++ b/internal/web/web_shutdown_test.go @@ -10,7 +10,7 @@ import ( func TestServerStopWhenXrayNotRunningSucceeds(t *testing.T) { t.Setenv("XUI_DB_FOLDER", t.TempDir()) - s := NewServer() + s := NewServer(nil) if err := s.Stop(); err != nil { t.Fatalf("expected nil error when stopping server with unstarted xray, got: %v", err) } @@ -18,7 +18,7 @@ func TestServerStopWhenXrayNotRunningSucceeds(t *testing.T) { func TestServerStopPanelOnlyWithPreclosedListenerSucceeds(t *testing.T) { t.Setenv("XUI_DB_FOLDER", t.TempDir()) - s := NewServer() + s := NewServer(nil) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen failed: %v", err) diff --git a/internal/web/web_sublink_test.go b/internal/web/web_sublink_test.go new file mode 100644 index 000000000..3b4267aea --- /dev/null +++ b/internal/web/web_sublink_test.go @@ -0,0 +1,40 @@ +package web + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +type fakeSubLinkProvider struct { + links []string +} + +func (f fakeSubLinkProvider) SubLinksForSubId(host, subId string) ([]string, error) { + return f.links, nil +} + +func (f fakeSubLinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string { + return f.links +} + +func (f fakeSubLinkProvider) LinksForInbounds(host string, inbounds []*model.Inbound) []string { + return f.links +} + +// A link request can arrive as soon as the panel serves, so NewServer must install the provider. +func TestNewServerInstallsTheShareLinkProviderBeforeServing(t *testing.T) { + service.RegisterSubLinkProvider(nil) + t.Cleanup(func() { service.RegisterSubLinkProvider(nil) }) + + NewServer(fakeSubLinkProvider{links: []string{"vless://example"}}) + + links, err := (&service.InboundService{}).GetSubLinks("panel.example", "subid") + if err != nil { + t.Fatalf("the share-link provider must be installed before the panel serves: %v", err) + } + if len(links) != 1 || links[0] != "vless://example" { + t.Fatalf("links = %v, want the provider's links", links) + } +} diff --git a/internal/web/web_tuic_shutdown_test.go b/internal/web/web_tuic_shutdown_test.go index a861d2419..801486c2e 100644 --- a/internal/web/web_tuic_shutdown_test.go +++ b/internal/web/web_tuic_shutdown_test.go @@ -74,7 +74,7 @@ func TestGracefulShutdownPersistsFinalTuicTraffic(t *testing.T) { t.Fatal("TUIC listener did not start") } - if err := NewServer().Stop(); err != nil { + if err := NewServer(nil).Stop(); err != nil { t.Fatalf("graceful server shutdown: %v", err) } diff --git a/main.go b/main.go index 559b78696..aa2010959 100644 --- a/main.go +++ b/main.go @@ -121,7 +121,7 @@ func runWebServer() { log.Printf("No panel user existed, so one was generated. Username: %s Password: %s (shown once; change it in Panel Settings)", username, password) } - server := web.NewServer() + server := web.NewServer(sub.NewLinkProvider()) global.SetWebServer(server) err = server.Start() if err != nil { @@ -129,7 +129,6 @@ func runWebServer() { } sub.SetDistFS(web.EmbeddedDist()) - service.RegisterSubLinkProvider(sub.NewLinkProvider()) subServer := sub.NewServer() global.SetSubServer(subServer) err = subServer.Start() @@ -183,7 +182,7 @@ func runWebServer() { logger.Debug("Error stopping sub server:", err) } - server = web.NewServer() + server = web.NewServer(sub.NewLinkProvider()) global.SetWebServer(server) err = server.StartPanelOnly() if err != nil {