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.
This commit is contained in:
25f2002654
2026-10-24 09:20:46 +05:30
committed by GitHub
parent ad470681ef
commit d2a0216e01
9 changed files with 125 additions and 20 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ func startContractNode(t *testing.T) *nodeUnderContract {
t.Cleanup(func() { runtime.SetManager(prevMgr) }) t.Cleanup(func() { runtime.SetManager(prevMgr) })
previous := global.GetWebServer() previous := global.GetWebServer()
s := NewServer() s := NewServer(nil)
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds()) s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
global.SetWebServer(s) global.SetWebServer(s)
t.Cleanup(func() { t.Cleanup(func() {
+1 -1
View File
@@ -47,7 +47,7 @@ func registeredContractRoutes(t *testing.T) map[string]bool {
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
previous := global.GetWebServer() previous := global.GetWebServer()
s := NewServer() s := NewServer(nil)
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds()) s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
global.SetWebServer(s) global.SetWebServer(s)
t.Cleanup(func() { t.Cleanup(func() {
+24 -7
View File
@@ -1,6 +1,8 @@
package service package service
import ( import (
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
) )
@@ -11,35 +13,50 @@ type SubLinkProvider interface {
LinksForInbounds(host string, inbounds []*model.Inbound) []string LinksForInbounds(host string, inbounds []*model.Inbound) []string
} }
var registeredSubLinkProvider SubLinkProvider var (
subLinkProviderMu sync.RWMutex
registeredSubLinkProvider SubLinkProvider
)
func RegisterSubLinkProvider(p SubLinkProvider) { func RegisterSubLinkProvider(p SubLinkProvider) {
subLinkProviderMu.Lock()
defer subLinkProviderMu.Unlock()
registeredSubLinkProvider = p 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) { 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 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) { 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") return nil, common.NewError("sub link provider not registered")
} }
inbounds, err := s.GetInbounds(userId) inbounds, err := s.GetInbounds(userId)
if err != nil { if err != nil {
return nil, err 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) { func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) {
if email == "" { if email == "" {
return nil, common.NewError("client email is required") 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") return nil, common.NewError("sub link provider not registered")
} }
rec, err := s.clientService.GetRecordByEmail(nil, email) rec, err := s.clientService.GetRecordByEmail(nil, email)
@@ -56,7 +73,7 @@ func (s *InboundService) GetAllClientLinks(host string, email string) ([]string,
if getErr != nil { if getErr != nil {
return nil, getErr return nil, getErr
} }
links = append(links, registeredSubLinkProvider.LinksForClient(host, inbound, email)...) links = append(links, provider.LinksForClient(host, inbound, email)...)
} }
return links, nil return links, nil
} }
@@ -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()
}
+10 -5
View File
@@ -138,8 +138,12 @@ type Server struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
// NewServer creates a new web server instance with a cancellable context. // NewServer installs the share-link provider before anything serves, so an early link request
func NewServer() *Server { // 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()) ctx, cancel := context.WithCancel(context.Background())
return &Server{ return &Server{
ctx: ctx, 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. // add/update/delete to either the local xray or a remote node panel.
// The closures bridge into XrayService (which owns the running xray // The closures bridge into XrayService (which owns the running xray
// process state) without forcing the runtime package to import service. // 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() }, APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() }, 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 // 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. // from the node CA on first use; runtime stays free of a service import.
runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) { runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
func TestServerStopWhenXrayNotRunningSucceeds(t *testing.T) { func TestServerStopWhenXrayNotRunningSucceeds(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir()) t.Setenv("XUI_DB_FOLDER", t.TempDir())
s := NewServer() s := NewServer(nil)
if err := s.Stop(); err != nil { if err := s.Stop(); err != nil {
t.Fatalf("expected nil error when stopping server with unstarted xray, got: %v", err) 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) { func TestServerStopPanelOnlyWithPreclosedListenerSucceeds(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir()) t.Setenv("XUI_DB_FOLDER", t.TempDir())
s := NewServer() s := NewServer(nil)
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatalf("net.Listen failed: %v", err) t.Fatalf("net.Listen failed: %v", err)
+40
View File
@@ -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)
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ func TestGracefulShutdownPersistsFinalTuicTraffic(t *testing.T) {
t.Fatal("TUIC listener did not start") 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) t.Fatalf("graceful server shutdown: %v", err)
} }
+2 -3
View File
@@ -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) 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) global.SetWebServer(server)
err = server.Start() err = server.Start()
if err != nil { if err != nil {
@@ -129,7 +129,6 @@ func runWebServer() {
} }
sub.SetDistFS(web.EmbeddedDist()) sub.SetDistFS(web.EmbeddedDist())
service.RegisterSubLinkProvider(sub.NewLinkProvider())
subServer := sub.NewServer() subServer := sub.NewServer()
global.SetSubServer(subServer) global.SetSubServer(subServer)
err = subServer.Start() err = subServer.Start()
@@ -183,7 +182,7 @@ func runWebServer() {
logger.Debug("Error stopping sub server:", err) logger.Debug("Error stopping sub server:", err)
} }
server = web.NewServer() server = web.NewServer(sub.NewLinkProvider())
global.SetWebServer(server) global.SetWebServer(server)
err = server.StartPanelOnly() err = server.StartPanelOnly()
if err != nil { if err != nil {