// Package sub provides subscription server functionality for the 3x-ui panel, // including HTTP/HTTPS servers for serving subscription links and JSON configurations. package sub import ( "context" "crypto/tls" "io" "io/fs" "net" "net/http" "os" "strconv" "strings" "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/util/common" webpkg "github.com/mhsanaei/3x-ui/v3/web" "github.com/mhsanaei/3x-ui/v3/web/locale" "github.com/mhsanaei/3x-ui/v3/web/middleware" "github.com/mhsanaei/3x-ui/v3/web/network" "github.com/mhsanaei/3x-ui/v3/web/service" "github.com/gin-gonic/gin" ) // Server represents the subscription server that serves subscription links and JSON configurations. type Server struct { httpServer *http.Server listener net.Listener sub *SUBController settingService service.SettingService ctx context.Context cancel context.CancelFunc } // NewServer creates a new subscription server instance with a cancellable context. func NewServer() *Server { ctx, cancel := context.WithCancel(context.Background()) return &Server{ ctx: ctx, cancel: cancel, } } // initRouter configures the subscription server's Gin engine, middleware, // templates and static assets and returns the ready-to-use engine. func (s *Server) initRouter() (*gin.Engine, error) { // Always run in release mode for the subscription server gin.DefaultWriter = io.Discard gin.DefaultErrorWriter = io.Discard gin.SetMode(gin.ReleaseMode) engine := gin.Default() subDomain, err := s.settingService.GetSubDomain() if err != nil { return nil, err } if subDomain != "" { engine.Use(middleware.DomainValidatorMiddleware(subDomain)) } LinksPath, err := s.settingService.GetSubPath() if err != nil { return nil, err } JsonPath, err := s.settingService.GetSubJsonPath() if err != nil { return nil, err } ClashPath, err := s.settingService.GetSubClashPath() if err != nil { return nil, err } subJsonEnable, err := s.settingService.GetSubJsonEnable() if err != nil { return nil, err } subClashEnable, err := s.settingService.GetSubClashEnable() if err != nil { return nil, err } // Set base_path based on LinksPath for template rendering // Ensure LinksPath ends with "/" for proper asset URL generation basePath := LinksPath if basePath != "/" && !strings.HasSuffix(basePath, "/") { basePath += "/" } // logger.Debug("sub: Setting base_path to:", basePath) engine.Use(func(c *gin.Context) { c.Set("base_path", basePath) }) Encrypt, err := s.settingService.GetSubEncrypt() if err != nil { return nil, err } ShowInfo, err := s.settingService.GetSubShowInfo() if err != nil { return nil, err } RemarkModel, err := s.settingService.GetRemarkModel() if err != nil { RemarkModel = "-ieo" } SubUpdates, err := s.settingService.GetSubUpdates() if err != nil { SubUpdates = "10" } SubJsonFragment, err := s.settingService.GetSubJsonFragment() if err != nil { SubJsonFragment = "" } SubJsonNoises, err := s.settingService.GetSubJsonNoises() if err != nil { SubJsonNoises = "" } SubJsonMux, err := s.settingService.GetSubJsonMux() if err != nil { SubJsonMux = "" } SubJsonRules, err := s.settingService.GetSubJsonRules() if err != nil { SubJsonRules = "" } SubTitle, err := s.settingService.GetSubTitle() if err != nil { SubTitle = "" } SubSupportUrl, err := s.settingService.GetSubSupportUrl() if err != nil { SubSupportUrl = "" } SubProfileUrl, err := s.settingService.GetSubProfileUrl() if err != nil { SubProfileUrl = "" } SubAnnounce, err := s.settingService.GetSubAnnounce() if err != nil { SubAnnounce = "" } SubEnableRouting, err := s.settingService.GetSubEnableRouting() if err != nil { return nil, err } SubRoutingRules, err := s.settingService.GetSubRoutingRules() if err != nil { SubRoutingRules = "" } // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) // Mount the Vite-built dist/assets/ so the subscription page's JS/CSS // bundles load from `/assets/...`. Also mount the same FS under the // subscription path prefix (LinksPath + "assets") so reverse proxies // running the panel under a URI prefix can resolve those URLs too. // Note: LinksPath always starts and ends with "/" (validated in settings). var linksPathForAssets string if LinksPath == "/" { linksPathForAssets = "/assets" } else { linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets" } var assetsFS http.FileSystem if _, err := os.Stat("web/dist/assets"); err == nil { assetsFS = http.FS(os.DirFS("web/dist/assets")) } else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil { assetsFS = http.FS(subFS) } else { logger.Error("sub: failed to mount embedded dist assets:", err) } if assetsFS != nil { engine.StaticFS("/assets", assetsFS) if linksPathForAssets != "/assets" { engine.StaticFS(linksPathForAssets, assetsFS) } // Browser may resolve subpage assets relative to the request URL — // /sub///assets/... — so route those to the same FS. if LinksPath != "/" { engine.Use(func(c *gin.Context) { path := c.Request.URL.Path pathPrefix := strings.TrimRight(LinksPath, "/") + "/" if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") { assetsIndex := strings.Index(path, "/assets/") if assetsIndex != -1 { assetPath := path[assetsIndex+8:] // +8 to skip "/assets/" if assetPath != "" { c.FileFromFS(assetPath, assetsFS) c.Abort() return } } } c.Next() }) } } g := engine.Group("/") s.sub = NewSUBController( g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) return engine, nil } // Start initializes and starts the subscription server with configured settings. func (s *Server) Start() (err error) { // This is an anonymous function, no function name defer func() { if err != nil { s.Stop() } }() subEnable, err := s.settingService.GetSubEnable() if err != nil { return err } if !subEnable { return nil } engine, err := s.initRouter() if err != nil { return err } certFile, err := s.settingService.GetSubCertFile() if err != nil { return err } keyFile, err := s.settingService.GetSubKeyFile() if err != nil { return err } listen, err := s.settingService.GetSubListen() if err != nil { return err } port, err := s.settingService.GetSubPort() if err != nil { return err } listenAddr := net.JoinHostPort(listen, strconv.Itoa(port)) listener, err := net.Listen("tcp", listenAddr) if err != nil { return err } if certFile != "" || keyFile != "" { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err == nil { c := &tls.Config{ Certificates: []tls.Certificate{cert}, } listener = network.NewAutoHttpsListener(listener) listener = tls.NewListener(listener, c) logger.Info("Sub server running HTTPS on", listener.Addr()) } else { logger.Error("Error loading certificates:", err) logger.Info("Sub server running HTTP on", listener.Addr()) } } else { logger.Info("Sub server running HTTP on", listener.Addr()) } s.listener = listener s.httpServer = &http.Server{ Handler: engine, } go func() { s.httpServer.Serve(listener) }() return nil } // Stop gracefully shuts down the subscription server and closes the listener. func (s *Server) Stop() error { s.cancel() var err1 error var err2 error if s.httpServer != nil { err1 = s.httpServer.Shutdown(s.ctx) } if s.listener != nil { err2 = s.listener.Close() } return common.Combine(err1, err2) } // GetCtx returns the server's context for cancellation and deadline management. func (s *Server) GetCtx() context.Context { return s.ctx }