// Package web provides the main web server implementation for the 3x-ui panel, // including HTTP/HTTPS serving, routing, templates, and background job scheduling. package web import ( "context" "crypto/sha256" "crypto/tls" "embed" "html/template" "io" "io/fs" "net" "net/http" "os" "strconv" "time" "fmt" "github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/redis" "github.com/mhsanaei/3x-ui/v2/web/controller" "github.com/mhsanaei/3x-ui/v2/web/job" "github.com/mhsanaei/3x-ui/v2/web/locale" "github.com/mhsanaei/3x-ui/v2/web/middleware" "github.com/mhsanaei/3x-ui/v2/web/network" "github.com/mhsanaei/3x-ui/v2/web/service" "github.com/gin-contrib/gzip" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" ) //go:embed assets var assetsFS embed.FS //go:embed html/* var htmlFS embed.FS //go:embed translation/* var i18nFS embed.FS var startTime = time.Now() type wrapAssetsFS struct { embed.FS } func (f *wrapAssetsFS) Open(name string) (fs.File, error) { file, err := f.FS.Open("assets/" + name) if err != nil { return nil, err } return &wrapAssetsFile{File: file}, nil } type wrapAssetsFile struct { fs.File } func (f *wrapAssetsFile) Stat() (fs.FileInfo, error) { info, err := f.File.Stat() if err != nil { return nil, err } return &wrapAssetsFileInfo{FileInfo: info}, nil } type wrapAssetsFileInfo struct { fs.FileInfo } func (f *wrapAssetsFileInfo) ModTime() time.Time { return startTime } // EmbeddedHTML returns the embedded HTML templates filesystem for reuse by other servers. func EmbeddedHTML() embed.FS { return htmlFS } // EmbeddedAssets returns the embedded assets filesystem for reuse by other servers. func EmbeddedAssets() embed.FS { return assetsFS } // Server represents the main web server for the 3x-ui panel with controllers, services, and scheduled jobs. type Server struct { httpServer *http.Server listener net.Listener index *controller.IndexController panel *controller.XUIController api *controller.APIController xrayService service.XrayService settingService service.SettingService tgbotService service.Tgbot wsService *service.WebSocketService cron *cron.Cron ctx context.Context cancel context.CancelFunc } // NewServer creates a new web server instance with a cancellable context. func NewServer() *Server { ctx, cancel := context.WithCancel(context.Background()) return &Server{ctx: ctx, cancel: cancel} } // getHtmlFiles walks the local `web/html` directory and returns a list of // template file paths. Used only in debug/development mode. func (s *Server) getHtmlFiles() ([]string, error) { files := make([]string, 0) dir, _ := os.Getwd() err := fs.WalkDir(os.DirFS(dir), "web/html", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } files = append(files, path) return nil }) if err != nil { return nil, err } return files, nil } // getHtmlTemplate parses embedded HTML templates from the bundled `htmlFS`. func (s *Server) getHtmlTemplate(funcMap template.FuncMap) (*template.Template, error) { t := template.New("").Funcs(funcMap) err := fs.WalkDir(htmlFS, "html", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { newT, err := t.ParseFS(htmlFS, path+"/*.html") if err != nil { // ignore folders without matches return nil } t = newT } return nil }) if err != nil { return nil, err } return t, nil } // initRouter initializes Gin, registers middleware, templates, static assets, // controllers and returns the configured engine. func (s *Server) initRouter() (*gin.Engine, error) { if config.IsDebug() { gin.SetMode(gin.DebugMode) } else { gin.DefaultWriter = io.Discard gin.DefaultErrorWriter = io.Discard gin.SetMode(gin.ReleaseMode) } engine := gin.Default() // получаем домен и секрет/базовый путь из настроек webDomain, err := s.settingService.GetWebDomain() if err != nil { return nil, err } if webDomain != "" { engine.Use(middleware.DomainValidatorMiddleware(webDomain)) } // вот ЭТО должно быть раньше, чем блок с сессиями: secret, err := s.settingService.GetSecret() if err != nil { return nil, err } basePath, err := s.settingService.GetBasePath() if err != nil { return nil, err } // cookie-сессии на базе секретного ключа key := sha256.Sum256([]byte(secret)) store := cookie.NewStore(key[:]) store.Options(sessions.Options{ Path: basePath, HttpOnly: true, Secure: false, // если HTTPS — поставить true SameSite: http.SameSiteLaxMode, }) engine.Use(sessions.Sessions("xui_sess", store)) // Initialize Redis (in-memory fallback) redis.Init("", "", 0) // Uses in-memory fallback // Security middlewares (configurable) rateLimitEnabled, _ := s.settingService.GetRateLimitEnabled() if rateLimitEnabled { rateLimitRequests, _ := s.settingService.GetRateLimitRequests() if rateLimitRequests <= 0 { rateLimitRequests = 60 } rateLimitBurst, _ := s.settingService.GetRateLimitBurst() if rateLimitBurst <= 0 { rateLimitBurst = 10 } config := middleware.RateLimitConfig{ RequestsPerMinute: rateLimitRequests, BurstSize: rateLimitBurst, KeyFunc: func(c *gin.Context) string { return c.ClientIP() }, SkipPaths: []string{basePath + "assets/", "/favicon.ico"}, } engine.Use(middleware.RateLimitMiddleware(config)) } ipFilterEnabled, _ := s.settingService.GetIPFilterEnabled() if ipFilterEnabled { whitelistEnabled, _ := s.settingService.GetIPWhitelistEnabled() blacklistEnabled, _ := s.settingService.GetIPBlacklistEnabled() engine.Use(middleware.IPFilterMiddleware(middleware.IPFilterConfig{ WhitelistEnabled: whitelistEnabled, BlacklistEnabled: blacklistEnabled, GeoIPEnabled: false, // TODO: Add GeoIP config SkipPaths: []string{basePath + "assets/", "/favicon.ico"}, })) } engine.Use(middleware.SessionSecurityMiddleware()) // Audit logging middleware (after session check) engine.Use(middleware.AuditMiddleware()) // gzip, excluding API path to avoid double-compressing JSON where needed engine.Use(gzip.Gzip( gzip.DefaultCompression, gzip.WithExcludedPaths([]string{basePath + "panel/api/"}), )) // i18n in templates i18nWebFunc := func(key string, params ...string) string { return locale.I18n(locale.Web, key, params...) } funcMap := template.FuncMap{"i18n": i18nWebFunc} engine.SetFuncMap(funcMap) // Static files & templates if config.IsDebug() { files, err := s.getHtmlFiles() if err != nil { return nil, err } engine.LoadHTMLFiles(files...) engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets"))) } else { tpl, err := s.getHtmlTemplate(funcMap) if err != nil { return nil, err } engine.SetHTMLTemplate(tpl) engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: assetsFS})) } // API api := engine.Group(basePath + "panel/api") { // controller.NewAuthController(api) controller.NewUserAdminController(api) // New feature controllers controller.NewAuditController(api) controller.NewAnalyticsController(api) controller.NewQuotaController(api) controller.NewOnboardingController(api) controller.NewReportsController(api) } // Redirects (/xui -> /panel etc.) engine.Use(middleware.RedirectMiddleware(basePath)) // Web UI groups g := engine.Group(basePath) s.index = controller.NewIndexController(g) s.panel = controller.NewXUIController(g) s.api = controller.NewAPIController(g) // WebSocket for real-time updates s.wsService = service.NewWebSocketService(s.xrayService) go s.wsService.Run() controller.NewWebSocketController(g, s.wsService) // Chrome DevTools endpoint for debugging web apps engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) }) // 404 handler engine.NoRoute(func(c *gin.Context) { c.AbortWithStatus(http.StatusNotFound) }) return engine, nil } // startTask schedules background jobs (Xray checks, traffic jobs, cron jobs). func (s *Server) startTask() { if err := s.xrayService.RestartXray(true); err != nil { logger.Warning("start xray failed:", err) } // Check whether xray is running every second s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob()) // Check if xray needs to be restarted every 30 seconds s.cron.AddFunc("@every 30s", func() { if s.xrayService.IsNeedRestartAndSetFalse() { if err := s.xrayService.RestartXray(false); err != nil { logger.Error("restart xray failed:", err) } } }) // Traffic stats every 10s (with initial 5s delay) go func() { time.Sleep(5 * time.Second) s.cron.AddJob("@every 10s", job.NewXrayTrafficJob()) }() // Client IP checks & maintenance s.cron.AddJob("@every 10s", job.NewCheckClientIpJob()) s.cron.AddJob("@daily", job.NewClearLogsJob()) // Periodic traffic resets s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily")) s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly")) s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly")) // LDAP sync if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled { runtime, err := s.settingService.GetLdapSyncCron() if err != nil || runtime == "" { runtime = "@every 1m" } s.cron.AddJob(runtime, job.NewLdapSyncJob()) } // Quota check (configurable interval) quotaInterval, err := s.settingService.GetQuotaCheckInterval() if err != nil || quotaInterval <= 0 { quotaInterval = 5 // Default 5 minutes } s.cron.AddJob(fmt.Sprintf("@every %dm", quotaInterval), job.NewQuotaCheckJob()) // Weekly reports every Monday at 9 AM s.cron.AddJob("0 9 * * 1", job.NewReportsJob()) // Monthly reports on 1st of month at 9 AM s.cron.AddFunc("0 9 1 * *", func() { job.NewReportsJob().RunMonthly() }) // Audit log cleanup daily at 2 AM s.cron.AddJob("0 2 * * *", job.NewAuditCleanupJob()) // Clean expired Redis entries hourly s.cron.AddFunc("@hourly", func() { redis.CleanExpired() }) // Telegram bot related jobs if isTgbotenabled, err := s.settingService.GetTgbotEnabled(); (err == nil) && isTgbotenabled { runtime, err := s.settingService.GetTgbotRuntime() if err != nil || runtime == "" { logger.Errorf("Add NewStatsNotifyJob error[%s], Runtime[%s] invalid, will run default", err, runtime) runtime = "@daily" } logger.Infof("Tg notify enabled, run at %s", runtime) if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil { logger.Warning("Add NewStatsNotifyJob error", err) } s.cron.AddJob("@every 2m", job.NewCheckHashStorageJob()) if cpuThreshold, err := s.settingService.GetTgCpu(); (err == nil) && (cpuThreshold > 0) { s.cron.AddJob("@every 10s", job.NewCheckCpuJob()) } } } // Start initializes and starts the web server. func (s *Server) Start() (err error) { defer func() { if err != nil { _ = s.Stop() } }() loc, err := s.settingService.GetTimeLocation() if err != nil { return err } s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds()) s.cron.Start() engine, err := s.initRouter() if err != nil { return err } certFile, err := s.settingService.GetCertFile() if err != nil { return err } keyFile, err := s.settingService.GetKeyFile() if err != nil { return err } listen, err := s.settingService.GetListen() if err != nil { return err } port, err := s.settingService.GetPort() 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 != "" { if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err == nil { cfg := &tls.Config{Certificates: []tls.Certificate{cert}} listener = network.NewAutoHttpsListener(listener) listener = tls.NewListener(listener, cfg) logger.Info("Web server running HTTPS on", listener.Addr()) } else { logger.Error("Error loading certificates:", err) logger.Info("Web server running HTTP on", listener.Addr()) } } else { logger.Info("Web server running HTTP on", listener.Addr()) } s.listener = listener s.httpServer = &http.Server{Handler: engine} go func() { _ = s.httpServer.Serve(listener) }() s.startTask() if isTgbotenabled, err := s.settingService.GetTgbotEnabled(); (err == nil) && isTgbotenabled { tgBot := s.tgbotService.NewTgbot() tgBot.Start(i18nFS) } return nil } // Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot. func (s *Server) Stop() error { s.cancel() s.xrayService.StopXray() if s.cron != nil { s.cron.Stop() } if s.tgbotService.IsRunning() { s.tgbotService.Stop() } var err1, 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. func (s *Server) GetCtx() context.Context { return s.ctx } // GetCron returns the server's cron scheduler instance. func (s *Server) GetCron() *cron.Cron { return s.cron }