Files
3x-ui-agent/internal/web/web.go
T
Muse a654a96c38
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 23:39:38 +08:00

575 lines
18 KiB
Go

// 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/tls"
"embed"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
"github.com/mhsanaei/3x-ui/v3/internal/web/controller"
"github.com/mhsanaei/3x-ui/v3/internal/web/job"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/network"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"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 translation/*
var i18nFS embed.FS
// distFS embeds the Vite-built frontend (internal/web/dist/). Every user-facing
// HTML route is served straight out of this FS — the legacy Go
// templates and `web/assets/` tree are gone post-Phase 8.
//go:embed all:dist
var distFS embed.FS
var startTime = time.Now()
// cronPanicLogger adapts the package logger to cron's Printf-style logger so a
// panicking scheduled job is recovered and logged instead of crashing the panel.
type cronPanicLogger struct{}
func (cronPanicLogger) Printf(format string, args ...any) { logger.Errorf(format, args...) }
// wrapDistFS adapts the embedded `dist/` directory so it can be mounted
// as the panel's `/assets/` static route. Vite emits its bundled JS/CSS
// under `dist/assets/`; serving the FS rooted at `dist/assets` makes
// `/assets/<hash>.js` URLs resolve directly.
type wrapDistFS struct {
embed.FS
}
func (f *wrapDistFS) Open(name string) (fs.File, error) {
file, err := f.FS.Open("dist/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
}
// EmbeddedDist returns the embedded Vite-built frontend filesystem.
// Controllers serve their HTML out of this FS via the dist-page handler
// installed in NewEngine().
func EmbeddedDist() embed.FS {
return distFS
}
// 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
ws *controller.WebSocketController
xrayService service.XrayService
settingService service.SettingService
wsHub *websocket.Hub
bus *eventbus.Bus
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,
}
}
func (s *Server) isDirectHTTPSConfigured() bool {
certFile, certErr := s.settingService.GetCertFile()
keyFile, keyErr := s.settingService.GetKeyFile()
if certErr != nil || keyErr != nil || certFile == "" || keyFile == "" {
return false
}
_, err := tls.LoadX509KeyPair(certFile, keyFile)
return err == 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()
directHTTPS := s.isDirectHTTPSConfigured()
sendHSTS := directHTTPS && !config.IsSkipHSTS()
engine.Use(middleware.SecurityHeadersMiddleware(sendHSTS))
// Cap request bodies on state-changing requests so a stolen session/API
// token or a buggy client can't force large allocations or long DB
// transactions via bulk create/attach/import endpoints. GET/HEAD/OPTIONS
// carry no body and are left untouched. Database restore legitimately accepts
// large backups and streams them to disk, so only its exact route suffix is
// exempt. Follow-up: make the limit a setting.
const maxRequestBodyBytes = 10 << 20 // 10 MiB
engine.Use(middleware.MaxBodyBytes(maxRequestBodyBytes, "/panel/api/server/importDB"))
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
}
engine.Use(gzip.Gzip(gzip.DefaultCompression))
assetsBasePath := basePath + "assets/"
store := cookie.NewStore(secret)
// Configure default session cookie options, including expiration (MaxAge)
sessionOptions := sessions.Options{
Path: basePath,
HttpOnly: true,
Secure: directHTTPS,
SameSite: http.SameSiteLaxMode,
}
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {
sessionOptions.MaxAge = sessionMaxAge * 60 // minutes -> seconds
}
store.Options(sessionOptions)
engine.Use(sessions.Sessions("3x-ui", store))
engine.Use(func(c *gin.Context) {
c.Set("base_path", basePath)
})
engine.Use(func(c *gin.Context) {
uri := c.Request.RequestURI
if strings.HasPrefix(uri, assetsBasePath) {
c.Header("Cache-Control", "max-age=31536000")
}
})
// init i18n — still used by backend strings (errors, log messages,
// SubPage menu entries) even though the Go template engine is gone.
err = locale.InitLocalizer(i18nFS)
if err != nil {
return nil, err
}
engine.Use(locale.LocalizerMiddleware())
// `/assets/` serves the Vite-built bundle. In dev we pull from disk
// so the Vite watcher's incremental rebuilds show up without
// restarting the binary; in prod we serve the embedded dist FS
// rooted at `dist/assets/`.
if config.IsDebug() {
engine.StaticFS(basePath+"assets", http.FS(os.DirFS("internal/web/dist/assets")))
} else {
engine.StaticFS(basePath+"assets", http.FS(&wrapDistFS{FS: distFS}))
}
// Hand the embedded `dist/` filesystem to the controller package
// before any HTML-serving controller is constructed. Phase 8
// cutover: every HTML route reads from internal/web/dist/ instead of
// rendering a legacy template.
controller.SetDistFS(distFS)
g := engine.Group(basePath)
g.GET("/manifest.webmanifest", controller.ServePWAManifest)
g.GET("/pwa-register.js", controller.ServePWARegister)
g.GET("/service-worker.js", controller.ServePWAServiceWorker)
g.GET("/icons/:name", controller.ServePWAIcon)
s.index = controller.NewIndexController(g)
s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g)
// Initialize WebSocket hub
s.wsHub = websocket.NewHub()
go s.wsHub.Run()
// Initialize WebSocket controller — service owns per-connection pumps,
// controller is HTTP-layer only (auth + upgrade).
s.ws = controller.NewWebSocketController(panel.NewWebSocketService(s.wsHub))
// Register WebSocket route with basePath (g already has basePath prefix)
g.GET("/ws", s.ws.HandleWebSocket)
// 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{})
})
// Let unknown panel document routes fall back to the SPA shell, while every
// non-SPA miss still returns a hard 404.
engine.NoRoute(func(c *gin.Context) {
if s.panel.HandleNoRoutePanelSPA(c) {
return
}
c.AbortWithStatus(http.StatusNotFound)
})
return engine, nil
}
// Background-job cadences. Centralized here as the single tuning surface; the
// values are unchanged from the historical hardcoded cron specs. Follow-up:
// make these configurable via settings, add per-tick jitter to de-synchronize
// fleet load, skip expensive jobs when no WebSocket clients are connected or
// node/xray state is unchanged, and export per-job duration/skipped/error
// counters.
const (
cadenceXrayRunning = "@every 1s"
cadenceXrayRestart = "@every 30s"
cadenceXrayTraffic = "@every 5s"
cadenceMtproto = "@every 10s"
cadenceAmneziaWG = "@every 10s"
cadenceClientIPScan = "@every 10s"
cadenceNodeHeartbeat = "@every 5s"
cadenceNodeTraffic = "@every 5s"
cadenceOutboundSub = "@every 5m"
cadenceReapOrphans = "@every 5m"
cadenceXrayLogPrune = "@every 10m"
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
cadenceCPUAlarm = "@every 1m"
cadenceMemoryAlarm = "@every 1m"
)
// startTask schedules background jobs (Xray checks, traffic jobs, cron
// jobs) which the panel relies on for periodic maintenance and monitoring.
func (s *Server) startTask(restartXray bool, loc *time.Location) {
// Lite control-plane build: the local xray lifecycle is retired. The
// panel never starts a local core, so the crash watchdog
// (CheckXrayRunningJob), the pending-restart ticker
// (ApplyPendingRestart) and the local-core traffic collector
// (XrayTrafficJob) are not scheduled anymore; node traffic keeps flowing
// through NodeTrafficSyncJob from agent reports. The restartXray flag is
// retained for signature compatibility and intentionally unused.
_ = restartXray
// Reconcile mtproto (mtg) sidecars and scrape their traffic
mtJob := job.NewMtprotoJob()
_, _ = s.cron.AddJob(cadenceMtproto, mtJob)
go mtJob.Run()
// Reconcile embedded AmneziaWG interfaces; traffic rides Xray's own stats
awgJob := job.NewAmneziaWGJob()
_, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
go awgJob.Run()
// The client-IP limit job only reads the local xray core's online
// API, which never exists in this build, so it is not scheduled
// (it would idle every 10s against a dead API).
_, _ = s.cron.AddJob(cadenceNodeHeartbeat, job.NewNodeHeartbeatJob())
_, _ = s.cron.AddJob(cadenceNodeTraffic, job.NewNodeTrafficSyncJob())
// Outbound subscription auto-refresh (respects per-sub updateInterval)
_, _ = s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob())
_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
// check client ips from log file every day
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
_, _ = s.cron.AddJob("@hourly", job.NewWarpIpJob())
// Inbound traffic reset jobs
// Run every hour
_, _ = s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly", loc))
// Run once a day, midnight
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily", loc))
// Run once a week, midnight between Sat/Sun
_, _ = s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly", loc))
// Check monthly reset days at midnight
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
_, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
go func() {
time.Sleep(time.Minute)
job.NewMemoryReleaseJob().Run()
}()
}
}
// Start initializes and starts the web server with configured settings, routes, and background jobs.
func (s *Server) Start() (err error) {
return s.start(true)
}
func (s *Server) StartPanelOnly() (err error) {
return s.start(false)
}
func (s *Server) start(restartXray bool) (err error) {
// This is an anonymous function, no function name
defer func() {
if err != nil {
_ = s.Stop()
}
}()
loc, err := s.settingService.GetTimeLocation()
if err != nil {
return err
}
service.StartTrafficWriter()
// SkipIfStillRunning stops a slow job (e.g. the 5s traffic poll on a large
// install) from overlapping itself: two concurrent runs of the same job race
// the shared xrayAPI — leaking a grpc connection — and the StatsLastValues
// map, whose concurrent write is a fatal runtime throw cron.Recover can't
// catch. cron.Recover then logs any panic and keeps the scheduler alive.
s.cron = cron.New(
cron.WithLocation(loc),
cron.WithSeconds(),
cron.WithChain(
cron.SkipIfStillRunning(cron.DiscardLogger),
cron.Recover(cron.PrintfLogger(cronPanicLogger{})),
),
)
s.cron.Start()
// Wire the inbound-runtime manager once so InboundService can route
// add/update/delete to either the local xray or a remote node panel.
// The closures bridge into XrayService (which owns the running xray
// process state) without forcing the runtime package to import service.
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
}))
runtime.GetManager().SetNodeEgressResolver(&s.settingService)
// 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.
runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
ck, err := s.settingService.EnsureMasterClientCert()
if err != nil {
return tls.Certificate{}, err
}
return tls.X509KeyPair(ck.CertPEM, ck.KeyPEM)
})
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
}
if envPort, configured, envErr := config.GetPortOverride(); configured {
if envErr != nil {
logger.Warning("Ignoring invalid XUI_PORT; using configured web port:", port, envErr)
} else {
port = envPort
logger.Info("Using XUI_PORT override for web panel port:", port)
}
}
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "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},
}
// Opt-in node mTLS: when a trust CA is configured, request and verify
// client certs (VerifyClientCertIfGiven keeps browsers working). With
// no CA the listener is unchanged.
if pool, perr := s.settingService.NodeMtlsClientCAPool(); perr != nil {
logger.Warning("node mTLS: failed to build client CA trust pool:", perr)
} else if pool != nil {
applyNodeMtls(c, pool)
logger.Info("Node mTLS enabled: verifying client certificates for the node API")
}
listener = network.NewAutoHttpsListener(listener)
listener = tls.NewListener(listener, c)
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,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go network.ServeHTTP(s.httpServer, listener, "Web server")
// Create event bus before startTask so jobs can use it
s.bus = eventbus.New(eventbus.DefaultBufferSize)
service.SetEventBus(s.bus)
job.EventBus = s.bus
// Wire xray crash callback BEFORE startTask so it's ready
xray.OnCrash = func(err error) {
if s.bus != nil {
s.bus.Publish(eventbus.Event{
Type: eventbus.EventXrayCrash,
Data: err.Error(),
})
}
}
s.startTask(restartXray, loc)
return nil
}
// Stop gracefully shuts down the web server, stops Xray, and cron jobs.
func (s *Server) Stop() error {
return s.stop(true)
}
func (s *Server) StopPanelOnly() error {
return s.stop(false)
}
func (s *Server) stop(stopXray bool) error {
s.cancel()
if stopXray {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
amneziawgnet.GetManager().StopAll()
}
if s.cron != nil {
s.cron.Stop()
}
if s.bus != nil {
s.bus.Stop()
}
if err := service.PersistSystemMetrics(); err != nil {
logger.Warning("persist system metrics on shutdown failed:", err)
}
if stopXray {
service.StopTrafficWriter()
}
// Gracefully stop WebSocket hub
if s.wsHub != nil {
s.wsHub.Stop()
}
var err1 error
var err2 error
if s.httpServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
err1 = s.httpServer.Shutdown(shutdownCtx)
}
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
}
// GetCron returns the server's cron scheduler instance.
func (s *Server) GetCron() *cron.Cron {
return s.cron
}
// GetWSHub returns the WebSocket hub instance.
func (s *Server) GetWSHub() any {
return s.wsHub
}
func (s *Server) RestartXray() error {
return s.xrayService.RestartXray(true)
}