web.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  1. // Package web provides the main web server implementation for the 3x-ui panel,
  2. // including HTTP/HTTPS serving, routing, templates, and background job scheduling.
  3. package web
  4. import (
  5. "context"
  6. "crypto/tls"
  7. "embed"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "io/fs"
  12. "net"
  13. "net/http"
  14. "os"
  15. "strconv"
  16. "strings"
  17. "time"
  18. "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
  19. "github.com/mhsanaei/3x-ui/v3/internal/config"
  20. "github.com/mhsanaei/3x-ui/v3/internal/eventbus"
  21. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  22. "github.com/mhsanaei/3x-ui/v3/internal/mtproto"
  23. "github.com/mhsanaei/3x-ui/v3/internal/tuic"
  24. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  25. "github.com/mhsanaei/3x-ui/v3/internal/util/sys"
  26. "github.com/mhsanaei/3x-ui/v3/internal/web/controller"
  27. "github.com/mhsanaei/3x-ui/v3/internal/web/job"
  28. "github.com/mhsanaei/3x-ui/v3/internal/web/locale"
  29. "github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
  30. "github.com/mhsanaei/3x-ui/v3/internal/web/network"
  31. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  32. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  33. "github.com/mhsanaei/3x-ui/v3/internal/web/service/discord"
  34. "github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
  35. "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
  36. "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
  37. "github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
  38. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  39. "github.com/gin-contrib/gzip"
  40. "github.com/gin-contrib/sessions"
  41. "github.com/gin-contrib/sessions/cookie"
  42. "github.com/gin-gonic/gin"
  43. "github.com/robfig/cron/v3"
  44. )
  45. //go:embed translation/*
  46. var i18nFS embed.FS
  47. // distFS embeds the Vite-built frontend (internal/web/dist/). Every user-facing
  48. // HTML route is served straight out of this FS — the legacy Go
  49. // templates and `web/assets/` tree are gone post-Phase 8.
  50. //go:embed all:dist
  51. var distFS embed.FS
  52. var startTime = time.Now()
  53. // cronPanicLogger adapts the package logger to cron's Printf-style logger so a
  54. // panicking scheduled job is recovered and logged instead of crashing the panel.
  55. type cronPanicLogger struct{}
  56. func (cronPanicLogger) Printf(format string, args ...any) { logger.Errorf(format, args...) }
  57. // wrapDistFS adapts the embedded `dist/` directory so it can be mounted
  58. // as the panel's `/assets/` static route. Vite emits its bundled JS/CSS
  59. // under `dist/assets/`; serving the FS rooted at `dist/assets` makes
  60. // `/assets/<hash>.js` URLs resolve directly.
  61. type wrapDistFS struct {
  62. embed.FS
  63. }
  64. func (f *wrapDistFS) Open(name string) (fs.File, error) {
  65. file, err := f.FS.Open("dist/assets/" + name)
  66. if err != nil {
  67. return nil, err
  68. }
  69. return &wrapAssetsFile{
  70. File: file,
  71. }, nil
  72. }
  73. type wrapAssetsFile struct {
  74. fs.File
  75. }
  76. func (f *wrapAssetsFile) Stat() (fs.FileInfo, error) {
  77. info, err := f.File.Stat()
  78. if err != nil {
  79. return nil, err
  80. }
  81. return &wrapAssetsFileInfo{
  82. FileInfo: info,
  83. }, nil
  84. }
  85. type wrapAssetsFileInfo struct {
  86. fs.FileInfo
  87. }
  88. func (f *wrapAssetsFileInfo) ModTime() time.Time {
  89. return startTime
  90. }
  91. // EmbeddedDist returns the embedded Vite-built frontend filesystem.
  92. // Controllers serve their HTML out of this FS via the dist-page handler
  93. // installed in NewEngine().
  94. func EmbeddedDist() embed.FS {
  95. return distFS
  96. }
  97. // Server represents the main web server for the 3x-ui panel with controllers, services, and scheduled jobs.
  98. type Server struct {
  99. httpServer *http.Server
  100. listener net.Listener
  101. index *controller.IndexController
  102. panel *controller.XUIController
  103. api *controller.APIController
  104. ws *controller.WebSocketController
  105. xrayService service.XrayService
  106. settingService service.SettingService
  107. tgbotService tgbot.Tgbot
  108. discordService *discord.DiscordService
  109. discordGateway *discord.GatewayClient
  110. wsHub *websocket.Hub
  111. bus *eventbus.Bus
  112. cron *cron.Cron
  113. discordNotifyEntryID cron.EntryID
  114. ctx context.Context
  115. cancel context.CancelFunc
  116. }
  117. // NewServer creates a new web server instance with a cancellable context.
  118. func NewServer() *Server {
  119. ctx, cancel := context.WithCancel(context.Background())
  120. return &Server{
  121. ctx: ctx,
  122. cancel: cancel,
  123. }
  124. }
  125. func (s *Server) isDirectHTTPSConfigured() bool {
  126. certFile, certErr := s.settingService.GetCertFile()
  127. keyFile, keyErr := s.settingService.GetKeyFile()
  128. if certErr != nil || keyErr != nil || certFile == "" || keyFile == "" {
  129. return false
  130. }
  131. _, err := tls.LoadX509KeyPair(certFile, keyFile)
  132. return err == nil
  133. }
  134. // initRouter initializes Gin, registers middleware, templates, static
  135. // assets, controllers and returns the configured engine.
  136. func (s *Server) initRouter() (*gin.Engine, error) {
  137. if config.IsDebug() {
  138. gin.SetMode(gin.DebugMode)
  139. } else {
  140. gin.DefaultWriter = io.Discard
  141. gin.DefaultErrorWriter = io.Discard
  142. gin.SetMode(gin.ReleaseMode)
  143. }
  144. engine := gin.Default()
  145. directHTTPS := s.isDirectHTTPSConfigured()
  146. sendHSTS := directHTTPS && !config.IsSkipHSTS()
  147. engine.Use(middleware.SecurityHeadersMiddleware(sendHSTS))
  148. // Cap request bodies on state-changing requests so a stolen session/API
  149. // token or a buggy client can't force large allocations or long DB
  150. // transactions via bulk create/attach/import endpoints. GET/HEAD/OPTIONS
  151. // carry no body and are left untouched. Database restore legitimately accepts
  152. // large backups and streams them to disk, so only its exact route suffix is
  153. // exempt. Follow-up: make the limit a setting.
  154. const maxRequestBodyBytes = 10 << 20 // 10 MiB
  155. engine.Use(middleware.MaxBodyBytes(maxRequestBodyBytes, "/panel/api/server/importDB"))
  156. webDomain, err := s.settingService.GetWebDomain()
  157. if err != nil {
  158. return nil, err
  159. }
  160. if webDomain != "" {
  161. engine.Use(middleware.DomainValidatorMiddleware(webDomain))
  162. }
  163. secret, err := s.settingService.GetSecret()
  164. if err != nil {
  165. return nil, err
  166. }
  167. basePath, err := s.settingService.GetBasePath()
  168. if err != nil {
  169. return nil, err
  170. }
  171. engine.Use(gzip.Gzip(gzip.DefaultCompression))
  172. assetsBasePath := basePath + "assets/"
  173. store := cookie.NewStore(secret)
  174. // Configure default session cookie options, including expiration (MaxAge)
  175. sessionOptions := sessions.Options{
  176. Path: basePath,
  177. HttpOnly: true,
  178. Secure: directHTTPS,
  179. SameSite: http.SameSiteLaxMode,
  180. }
  181. if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {
  182. sessionOptions.MaxAge = sessionMaxAge * 60 // minutes -> seconds
  183. }
  184. store.Options(sessionOptions)
  185. engine.Use(sessions.Sessions("3x-ui", store))
  186. engine.Use(func(c *gin.Context) {
  187. c.Set("base_path", basePath)
  188. })
  189. engine.Use(func(c *gin.Context) {
  190. uri := c.Request.RequestURI
  191. if strings.HasPrefix(uri, assetsBasePath) {
  192. c.Header("Cache-Control", "max-age=31536000")
  193. }
  194. })
  195. // init i18n — still used by backend strings (errors, log messages,
  196. // SubPage menu entries) even though the Go template engine is gone.
  197. err = locale.InitLocalizer(i18nFS, &s.settingService)
  198. if err != nil {
  199. return nil, err
  200. }
  201. engine.Use(locale.LocalizerMiddleware())
  202. // `/assets/` serves the Vite-built bundle. In dev we pull from disk
  203. // so the Vite watcher's incremental rebuilds show up without
  204. // restarting the binary; in prod we serve the embedded dist FS
  205. // rooted at `dist/assets/`.
  206. if config.IsDebug() {
  207. engine.StaticFS(basePath+"assets", http.FS(os.DirFS("internal/web/dist/assets")))
  208. } else {
  209. engine.StaticFS(basePath+"assets", http.FS(&wrapDistFS{FS: distFS}))
  210. }
  211. // Hand the embedded `dist/` filesystem to the controller package
  212. // before any HTML-serving controller is constructed. Phase 8
  213. // cutover: every HTML route reads from internal/web/dist/ instead of
  214. // rendering a legacy template.
  215. controller.SetDistFS(distFS)
  216. g := engine.Group(basePath)
  217. g.GET("/manifest.webmanifest", controller.ServePWAManifest)
  218. g.GET("/pwa-register.js", controller.ServePWARegister)
  219. g.GET("/service-worker.js", controller.ServePWAServiceWorker)
  220. g.GET("/icons/:name", controller.ServePWAIcon)
  221. s.index = controller.NewIndexController(g)
  222. s.panel = controller.NewXUIController(g)
  223. s.api = controller.NewAPIController(g)
  224. // Initialize WebSocket hub
  225. s.wsHub = websocket.NewHub()
  226. go s.wsHub.Run()
  227. // Initialize WebSocket controller — service owns per-connection pumps,
  228. // controller is HTTP-layer only (auth + upgrade).
  229. s.ws = controller.NewWebSocketController(panel.NewWebSocketService(s.wsHub))
  230. // Register WebSocket route with basePath (g already has basePath prefix)
  231. g.GET("/ws", s.ws.HandleWebSocket)
  232. // Chrome DevTools endpoint for debugging web apps
  233. engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
  234. c.JSON(http.StatusOK, gin.H{})
  235. })
  236. // Let unknown panel document routes fall back to the SPA shell, while every
  237. // non-SPA miss still returns a hard 404.
  238. engine.NoRoute(func(c *gin.Context) {
  239. if s.panel.HandleNoRoutePanelSPA(c) {
  240. return
  241. }
  242. c.AbortWithStatus(http.StatusNotFound)
  243. })
  244. return engine, nil
  245. }
  246. // Background-job cadences. Centralized here as the single tuning surface; the
  247. // values are unchanged from the historical hardcoded cron specs. Follow-up:
  248. // make these configurable via settings, add per-tick jitter to de-synchronize
  249. // fleet load, skip expensive jobs when no WebSocket clients are connected or
  250. // node/xray state is unchanged, and export per-job duration/skipped/error
  251. // counters.
  252. const (
  253. cadenceXrayRunning = "@every 1s"
  254. cadenceXrayRestart = "@every 30s"
  255. cadenceXrayTraffic = "@every 5s"
  256. cadenceMtproto = "@every 10s"
  257. cadenceAmneziaWG = "@every 10s"
  258. cadenceTuic = "@every 10s"
  259. cadenceClientIPScan = "@every 10s"
  260. cadenceNodeHeartbeat = "@every 5s"
  261. cadenceNodeTraffic = "@every 5s"
  262. cadenceOutboundSub = "@every 5m"
  263. cadenceReapOrphans = "@every 5m"
  264. cadenceRemoteRouting = "@every 5m"
  265. cadenceXrayLogPrune = "@every 10m"
  266. cadenceCheckHash = "@every 2m"
  267. // cpu.Percent samples over a full minute (blocking), so a finer cadence just
  268. // stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
  269. cadenceCPUAlarm = "@every 1m"
  270. cadenceMemoryAlarm = "@every 1m"
  271. )
  272. // startTask schedules background jobs (Xray checks, traffic jobs, cron
  273. // jobs) which the panel relies on for periodic maintenance and monitoring.
  274. func (s *Server) startTask(restartXray bool, loc *time.Location) {
  275. if restartXray {
  276. err := s.xrayService.RestartXray(true)
  277. if err != nil {
  278. logger.Warning("start xray failed:", err)
  279. }
  280. }
  281. // Check whether xray is running every second
  282. _, _ = s.cron.AddJob(cadenceXrayRunning, job.NewCheckXrayRunningJob())
  283. // Check if xray needs to be restarted every 30 seconds
  284. _, _ = s.cron.AddFunc(cadenceXrayRestart, func() {
  285. s.xrayService.ApplyPendingRestart()
  286. })
  287. go func() {
  288. time.Sleep(time.Second * 5)
  289. _, _ = s.cron.AddJob(cadenceXrayTraffic, job.NewXrayTrafficJob())
  290. }()
  291. // Reconcile mtproto (mtg) sidecars and scrape their traffic
  292. mtJob := job.NewMtprotoJob()
  293. _, _ = s.cron.AddJob(cadenceMtproto, mtJob)
  294. go mtJob.Run()
  295. // Reconcile embedded AmneziaWG interfaces; traffic rides Xray's own stats
  296. awgJob := job.NewAmneziaWGJob()
  297. _, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
  298. go awgJob.Run()
  299. tuicJob := job.NewTuicJob()
  300. _, _ = s.cron.AddJob(cadenceTuic, tuicJob)
  301. go tuicJob.Run()
  302. // check client ips from log file every 10 sec
  303. _, _ = s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
  304. _, _ = s.cron.AddJob(cadenceNodeHeartbeat, job.NewNodeHeartbeatJob())
  305. _, _ = s.cron.AddJob(cadenceNodeTraffic, job.NewNodeTrafficSyncJob())
  306. // Outbound subscription auto-refresh (respects per-sub updateInterval)
  307. _, _ = s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob())
  308. _, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
  309. // Warm permanent routing URLs immediately and refresh them outside the
  310. // latency-sensitive subscription request path.
  311. remoteRoutingJob := job.NewRemoteRoutingJob()
  312. _, _ = s.cron.AddJob(cadenceRemoteRouting, remoteRoutingJob)
  313. common.GoRecover("remote-routing-warm", remoteRoutingJob.Run)
  314. // check client ips from log file every day
  315. _, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
  316. _, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
  317. _, _ = s.cron.AddJob("@hourly", job.NewWarpIpJob())
  318. // Inbound traffic reset jobs
  319. // Run every hour
  320. _, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
  321. // Run once a day, midnight
  322. _, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
  323. // Run once a week, midnight between Sat/Sun
  324. _, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
  325. // Check monthly reset days at midnight
  326. _, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
  327. // LDAP sync scheduling
  328. if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
  329. runtime, err := s.settingService.GetLdapSyncCron()
  330. if err != nil || runtime == "" {
  331. runtime = "@every 1m"
  332. }
  333. j := job.NewLdapSyncJob()
  334. // job has zero-value services with method receivers that read settings on demand
  335. _, _ = s.cron.AddJob(runtime, j)
  336. }
  337. // Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
  338. isTgbotenabled, err := s.settingService.GetTgbotEnabled()
  339. if (err == nil) && isTgbotenabled {
  340. runtime, err := s.settingService.GetTgbotRuntime()
  341. if err != nil {
  342. logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
  343. runtime = "@daily"
  344. } else if strings.TrimSpace(runtime) == "" {
  345. logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
  346. runtime = "@daily"
  347. }
  348. logger.Infof("Tg notify enabled,run at %s", runtime)
  349. if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
  350. logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
  351. }
  352. // check for Telegram bot callback query hash storage reset
  353. _, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
  354. }
  355. // Discord-bot-dependent jobs: periodic stats report + database backup.
  356. isDiscordEnabled, err := s.settingService.GetDiscordBotEnable()
  357. if (err == nil) && isDiscordEnabled {
  358. runtime, err := s.settingService.GetDiscordRunTime()
  359. if err != nil {
  360. logger.Warningf("Add NewDiscordNotifyJob: failed to load runtime: %v; using default @daily", err)
  361. runtime = "@daily"
  362. } else if strings.TrimSpace(runtime) == "" {
  363. logger.Warning("Add NewDiscordNotifyJob runtime is empty, using default @daily")
  364. runtime = "@daily"
  365. }
  366. logger.Infof("Discord notify enabled, run at %s", runtime)
  367. if entryID, err := s.cron.AddJob(runtime, job.NewDiscordNotifyJob(s.discordService)); err != nil {
  368. logger.Warningf("Add NewDiscordNotifyJob: failed to schedule runtime %q: %v", runtime, err)
  369. } else {
  370. s.discordNotifyEntryID = entryID
  371. }
  372. }
  373. // CPU monitor publishes cpu.high events; register it whenever any notifier
  374. // (Telegram or Email) wants them, independent of the Telegram bot being on.
  375. if s.cpuAlarmWanted() {
  376. _, _ = s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
  377. }
  378. // Memory monitor publishes memory.high events; register it whenever any notifier wants them.
  379. if s.memoryAlarmWanted() {
  380. _, _ = s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob())
  381. }
  382. if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
  383. _, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
  384. go func() {
  385. time.Sleep(time.Minute)
  386. job.NewMemoryReleaseJob().Run()
  387. }()
  388. }
  389. }
  390. // cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
  391. // alerts, so the minute-long blocking CPU sampler only runs when it's needed.
  392. func (s *Server) cpuAlarmWanted() bool {
  393. wants := func(events string, threshold int) bool {
  394. if threshold <= 0 {
  395. return false
  396. }
  397. for e := range strings.SplitSeq(events, ",") {
  398. if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
  399. return true
  400. }
  401. }
  402. return false
  403. }
  404. if on, _ := s.settingService.GetTgbotEnabled(); on {
  405. events, _ := s.settingService.GetTgEnabledEvents()
  406. cpu, _ := s.settingService.GetTgCpu()
  407. if wants(events, cpu) {
  408. return true
  409. }
  410. }
  411. if on, _ := s.settingService.GetSmtpEnable(); on {
  412. events, _ := s.settingService.GetSmtpEnabledEvents()
  413. cpu, _ := s.settingService.GetSmtpCpu()
  414. if wants(events, cpu) {
  415. return true
  416. }
  417. }
  418. if on, _ := s.settingService.GetDiscordBotEnable(); on {
  419. events, _ := s.settingService.GetDiscordEnabledEvents()
  420. cpu, _ := s.settingService.GetDiscordCpu()
  421. if wants(events, cpu) {
  422. return true
  423. }
  424. }
  425. return false
  426. }
  427. // memoryAlarmWanted reports whether any notifier is configured to receive memory.high alerts.
  428. func (s *Server) memoryAlarmWanted() bool {
  429. wants := func(events string, threshold int) bool {
  430. if threshold <= 0 {
  431. return false
  432. }
  433. for e := range strings.SplitSeq(events, ",") {
  434. if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) {
  435. return true
  436. }
  437. }
  438. return false
  439. }
  440. if on, _ := s.settingService.GetTgbotEnabled(); on {
  441. events, _ := s.settingService.GetTgEnabledEvents()
  442. mem, _ := s.settingService.GetTgMemory()
  443. if wants(events, mem) {
  444. return true
  445. }
  446. }
  447. if on, _ := s.settingService.GetSmtpEnable(); on {
  448. events, _ := s.settingService.GetSmtpEnabledEvents()
  449. mem, _ := s.settingService.GetSmtpMemory()
  450. if wants(events, mem) {
  451. return true
  452. }
  453. }
  454. if on, _ := s.settingService.GetDiscordBotEnable(); on {
  455. events, _ := s.settingService.GetDiscordEnabledEvents()
  456. mem, _ := s.settingService.GetDiscordMemory()
  457. if wants(events, mem) {
  458. return true
  459. }
  460. }
  461. return false
  462. }
  463. // Start initializes and starts the web server with configured settings, routes, and background jobs.
  464. func (s *Server) Start() (err error) {
  465. return s.start(true, true)
  466. }
  467. func (s *Server) StartPanelOnly() (err error) {
  468. return s.start(false, true)
  469. }
  470. func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
  471. // This is an anonymous function, no function name
  472. defer func() {
  473. if err != nil {
  474. _ = s.Stop()
  475. }
  476. }()
  477. loc, err := s.settingService.GetTimeLocation()
  478. if err != nil {
  479. return err
  480. }
  481. service.StartTrafficWriter()
  482. // SkipIfStillRunning stops a slow job (e.g. the 5s traffic poll on a large
  483. // install) from overlapping itself: two concurrent runs of the same job race
  484. // the shared xrayAPI — leaking a grpc connection — and the StatsLastValues
  485. // map, whose concurrent write is a fatal runtime throw cron.Recover can't
  486. // catch. cron.Recover then logs any panic and keeps the scheduler alive.
  487. s.cron = cron.New(
  488. cron.WithLocation(loc),
  489. cron.WithSeconds(),
  490. cron.WithChain(
  491. cron.SkipIfStillRunning(cron.DiscardLogger),
  492. cron.Recover(cron.PrintfLogger(cronPanicLogger{})),
  493. ),
  494. )
  495. s.cron.Start()
  496. // Wire the inbound-runtime manager once so InboundService can route
  497. // add/update/delete to either the local xray or a remote node panel.
  498. // The closures bridge into XrayService (which owns the running xray
  499. // process state) without forcing the runtime package to import service.
  500. runtime.SetManager(runtime.NewManager(runtime.LocalDeps{
  501. APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
  502. SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
  503. }))
  504. runtime.GetManager().SetNodeEgressResolver(&s.settingService)
  505. // Supply the master client certificate for nodes in mtls mode. Issued lazily
  506. // from the node CA on first use; runtime stays free of a service import.
  507. runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
  508. ck, err := s.settingService.EnsureMasterClientCert()
  509. if err != nil {
  510. return tls.Certificate{}, err
  511. }
  512. return tls.X509KeyPair(ck.CertPEM, ck.KeyPEM)
  513. })
  514. engine, err := s.initRouter()
  515. if err != nil {
  516. return err
  517. }
  518. certFile, err := s.settingService.GetCertFile()
  519. if err != nil {
  520. return err
  521. }
  522. keyFile, err := s.settingService.GetKeyFile()
  523. if err != nil {
  524. return err
  525. }
  526. listen, err := s.settingService.GetListen()
  527. if err != nil {
  528. return err
  529. }
  530. port, err := s.settingService.GetPort()
  531. if err != nil {
  532. return err
  533. }
  534. if envPort, configured, envErr := config.GetPortOverride(); configured {
  535. if envErr != nil {
  536. logger.Warning("Ignoring invalid XUI_PORT; using configured web port:", port, envErr)
  537. } else {
  538. port = envPort
  539. logger.Info("Using XUI_PORT override for web panel port:", port)
  540. }
  541. }
  542. listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
  543. listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr)
  544. if err != nil {
  545. return err
  546. }
  547. if certFile != "" || keyFile != "" {
  548. cert, err := tls.LoadX509KeyPair(certFile, keyFile)
  549. if err == nil {
  550. c := &tls.Config{
  551. Certificates: []tls.Certificate{cert},
  552. }
  553. // Opt-in node mTLS: when a trust CA is configured, request and verify
  554. // client certs (VerifyClientCertIfGiven keeps browsers working). With
  555. // no CA the listener is unchanged.
  556. pool, perr := s.settingService.NodeMtlsClientCAPool()
  557. switch {
  558. case errors.Is(perr, service.ErrNodeMtlsTrustBundleInvalid):
  559. logger.Error("Node mTLS is configured but its trust bundle will not parse, so client certificates are not accepted:", perr)
  560. case perr != nil:
  561. logger.Error("Node mTLS trust bundle could not be read, so client certificates are not accepted:", perr)
  562. }
  563. if pool != nil {
  564. applyNodeMtls(c, pool)
  565. logger.Info("Node mTLS enabled: verifying client certificates for the node API")
  566. }
  567. listener = network.NewAutoHttpsListener(listener)
  568. listener = tls.NewListener(listener, c)
  569. logger.Info("Web server running HTTPS on", listener.Addr())
  570. } else {
  571. logger.Error("Error loading certificates:", err)
  572. logger.Info("Web server running HTTP on", listener.Addr())
  573. }
  574. } else {
  575. logger.Info("Web server running HTTP on", listener.Addr())
  576. }
  577. s.listener = listener
  578. s.httpServer = &http.Server{
  579. Handler: engine,
  580. ReadHeaderTimeout: 5 * time.Second,
  581. ReadTimeout: 30 * time.Second,
  582. WriteTimeout: 30 * time.Second,
  583. IdleTimeout: 120 * time.Second,
  584. }
  585. go network.ServeHTTP(s.httpServer, listener, "Web server")
  586. // Create event bus before startTask so jobs can use it
  587. s.bus = eventbus.New(eventbus.DefaultBufferSize)
  588. service.SetEventBus(s.bus)
  589. job.EventBus = s.bus
  590. tgbot.EventBus = s.bus
  591. // Wire xray crash callback BEFORE startTask so it's ready
  592. xray.OnCrash = func(err error) {
  593. if s.bus != nil {
  594. s.bus.Publish(eventbus.Event{
  595. Type: eventbus.EventXrayCrash,
  596. Data: err.Error(),
  597. })
  598. }
  599. }
  600. // Register email subscriber (always — it checks smtpEnable at runtime)
  601. emailService := email.NewEmailService(s.settingService)
  602. emailSub := email.NewSubscriber(s.settingService, emailService)
  603. s.bus.Subscribe("email-notifier", emailSub.HandleEvent)
  604. // Wire email service to controller for test endpoint
  605. controller.SetEmailService(emailService)
  606. // Register discord subscriber (always — it checks discordBotEnable at runtime)
  607. s.discordService = discord.NewDiscordService(s.settingService)
  608. discordSub := discord.NewSubscriber(s.settingService, s.discordService)
  609. s.bus.Subscribe("discord-notifier", discordSub.HandleEvent)
  610. // Wire discord service to controller for test endpoint
  611. controller.SetDiscordService(s.discordService)
  612. serverService := &service.ServerService{}
  613. inboundService := &service.InboundService{}
  614. s.discordGateway = discord.NewGatewayClient(s.discordService, s.settingService, serverService, inboundService, &s.xrayService)
  615. // Wire reload discord callback for settings updates
  616. controller.SetReloadDiscordFunc(func() {
  617. if s.discordNotifyEntryID != 0 {
  618. s.cron.Remove(s.discordNotifyEntryID)
  619. s.discordNotifyEntryID = 0
  620. }
  621. enabled, err := s.settingService.GetDiscordBotEnable()
  622. if err != nil || !enabled {
  623. if s.discordGateway != nil && s.discordGateway.IsRunning() {
  624. s.discordGateway.Stop()
  625. }
  626. return
  627. }
  628. runtime, err := s.settingService.GetDiscordRunTime()
  629. if err != nil || strings.TrimSpace(runtime) == "" {
  630. runtime = "@daily"
  631. }
  632. entryID, err := s.cron.AddJob(runtime, job.NewDiscordNotifyJob(s.discordService))
  633. if err != nil {
  634. logger.Warningf("Reload Discord notify: failed to schedule runtime %q: %v", runtime, err)
  635. } else {
  636. s.discordNotifyEntryID = entryID
  637. logger.Infof("Discord notify rescheduled, run at %s", runtime)
  638. }
  639. if s.discordGateway != nil && !s.discordGateway.IsRunning() {
  640. _ = s.discordGateway.Start(s.ctx)
  641. }
  642. })
  643. // Wire Telegram test function to controller
  644. controller.SetTestTgFunc(func() error {
  645. if !s.tgbotService.IsRunning() {
  646. return fmt.Errorf("telegram bot is not running (check token and chat ID)")
  647. }
  648. if err := s.tgbotService.TestConnection(); err != nil {
  649. return fmt.Errorf("telegram API test failed: %w", err)
  650. }
  651. s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
  652. return nil
  653. })
  654. controller.SetReloadTgbotFunc(func() {
  655. enabled, err := s.settingService.GetTgbotEnabled()
  656. if err != nil || !enabled {
  657. if s.tgbotService.IsRunning() {
  658. s.tgbotService.Stop()
  659. }
  660. if s.bus != nil {
  661. s.bus.Unsubscribe("tg-notifier")
  662. }
  663. return
  664. }
  665. // Start() stops any previous receiver first, so it is safe whether or not the bot is already running.
  666. tgBot := s.tgbotService.NewTgbot()
  667. if startErr := tgBot.Start(i18nFS); startErr != nil {
  668. logger.Warning("reload Telegram bot failed:", startErr)
  669. return
  670. }
  671. if s.bus != nil {
  672. s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
  673. }
  674. })
  675. s.startTask(restartXray, loc)
  676. if startTgBot {
  677. isTgbotenabled, err := s.settingService.GetTgbotEnabled()
  678. if (err == nil) && isTgbotenabled {
  679. tgBot := s.tgbotService.NewTgbot()
  680. _ = tgBot.Start(i18nFS)
  681. // Subscribe Telegram notifications for event bus
  682. s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
  683. }
  684. }
  685. isDiscordEnabled, err := s.settingService.GetDiscordBotEnable()
  686. if (err == nil) && isDiscordEnabled && s.discordGateway != nil {
  687. _ = s.discordGateway.Start(s.ctx)
  688. }
  689. return nil
  690. }
  691. // Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
  692. func (s *Server) Stop() error {
  693. return s.stop(true, true)
  694. }
  695. func (s *Server) StopPanelOnly() error {
  696. return s.stop(false, true)
  697. }
  698. func (s *Server) stop(stopXray bool, stopTgBot bool) error {
  699. s.cancel()
  700. if stopXray {
  701. _ = s.xrayService.StopXray()
  702. mtproto.GetManager().StopAll()
  703. amneziawgnet.GetManager().StopAll()
  704. tuic.GetManager().StopAll()
  705. amneziawgnet.GetOutboundManager().StopAll()
  706. }
  707. if s.cron != nil {
  708. s.cron.Stop()
  709. }
  710. if s.bus != nil {
  711. s.bus.Stop()
  712. }
  713. if err := service.PersistSystemMetrics(); err != nil {
  714. logger.Warning("persist system metrics on shutdown failed:", err)
  715. }
  716. if stopXray {
  717. service.StopTrafficWriter()
  718. }
  719. if stopTgBot && s.tgbotService.IsRunning() {
  720. s.tgbotService.Stop()
  721. }
  722. if s.discordGateway != nil && s.discordGateway.IsRunning() {
  723. s.discordGateway.Stop()
  724. }
  725. // Gracefully stop WebSocket hub
  726. if s.wsHub != nil {
  727. s.wsHub.Stop()
  728. }
  729. var err1 error
  730. var err2 error
  731. if s.httpServer != nil {
  732. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
  733. defer shutdownCancel()
  734. err1 = s.httpServer.Shutdown(shutdownCtx)
  735. }
  736. if s.listener != nil {
  737. err2 = s.listener.Close()
  738. }
  739. return common.Combine(err1, err2)
  740. }
  741. // GetCtx returns the server's context for cancellation and deadline management.
  742. func (s *Server) GetCtx() context.Context {
  743. return s.ctx
  744. }
  745. // GetCron returns the server's cron scheduler instance.
  746. func (s *Server) GetCron() *cron.Cron {
  747. return s.cron
  748. }
  749. // GetWSHub returns the WebSocket hub instance.
  750. func (s *Server) GetWSHub() any {
  751. return s.wsHub
  752. }
  753. func (s *Server) RestartXray() error {
  754. return s.xrayService.RestartXray(true)
  755. }