3x-ui/web/web.go

498 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 }