mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
37c5e0bfd2
* fix(api-docs): document clientIpsByGuid route
Restores a green `go test ./...` baseline: TestAPIRoutesDocumented
flagged POST /panel/api/clients/clientIpsByGuid (added in 9385b6c6)
as undocumented in endpoints.ts.
* test(node): characterize current node TLS + API auth behavior
Phase 0 regression net for the mTLS work. These pass on unchanged
production code and lock the pre-mTLS contracts so later phases can be
proven additive:
- tlsConfigForNode: skip -> InsecureSkipVerify (no VerifyConnection);
pin -> VerifyConnection installed.
- checkAPIAuth: bearer match -> Next + api_authed; unauthenticated ->
401 (XHR) / 404; valid session -> Next.
- panel HTTPS listener with no ClientAuth accepts a client that presents
no client certificate (the browsers-keep-working invariant).
* feat(crypto): node-auth CA + client-cert minting (TDD)
Stdlib-only ECDSA P-256 helpers for the node mTLS work:
- GenerateNodeCA: self-signed CA (IsCA, CertSign, path len 0)
- IssueClientCert: client-auth leaf (ExtKeyUsageClientAuth) signed by CA
- LoadCAFromPEM: parse a CA cert+key for issuing / trust-pool building
Tests assert the contract (leaf verifies against the issuing CA with
ExtKeyUsageClientAuth), seen failing on the assertion before impl.
* feat(node): lazy node mTLS CA + client cert in settings (TDD)
SettingService gains opt-in mTLS material, all stored as Setting rows
with empty defaults and kept out of entity.AllSetting (so private keys
never reach the settings UI/export):
- EnsureNodeMtlsCA: mint+persist the node-auth CA once, reuse thereafter
- EnsureMasterClientCert: issue the master client cert from the CA, idempotent
- NodeMtlsClientCAPool: ClientCAs trust pool for the listener; nil when
unconfigured so the no-mTLS path is unchanged
Tests assert idempotency and that the client cert verifies against the CA
for client auth; seen failing on the assertion before impl.
* feat(node): mtls client TLS config + master-cert provider (TDD)
tlsConfigForNode gains an 'mtls' branch that presents the master client
certificate and verifies the node server against system roots (no
InsecureSkipVerify, no custom RootCAs). The cert is supplied via an
injected MasterClientCertProvider so runtime need not import service;
it fails closed when unconfigured. skip/pin contracts unchanged.
* feat(node): allow tokenless mtls nodes in remote do() (TDD)
mtls nodes authenticate with a client certificate, so the bearer token
becomes optional for them: do() no longer rejects an empty ApiToken when
TlsVerifyMode is mtls, and the Authorization header is omitted when no
token is set. Every other mode still requires a token (regression kept).
* feat(node): authenticate verified client certs in checkAPIAuth (TDD)
A completed mTLS handshake (non-empty r.TLS.VerifiedChains) now
authenticates an API request, equivalent to a valid bearer token, and
sets api_authed so the CSRF middleware lets cert-authed mutations
through. Bearer/session/reject paths unchanged. The accept-path assert
was mutation-checked (guard flipped -> test red -> reverted).
* feat(node): opt-in mTLS on the panel listener (TDD; mutation-checked)
web.go now applies VerifyClientCertIfGiven + ClientCAs to the HTTPS
listener when a node trust CA is configured, and wires the master client
cert provider for outbound mtls calls. With no CA the listener is
byte-identical to before (browsers unaffected).
applyNodeMtls is covered end-to-end: no-cert client handshakes (browsers
keep working), a CA-signed client cert verifies, a foreign-CA cert is
rejected at the handshake. Mutation-checked:
- RequireAndVerifyClientCert -> no-cert client rejected (red) -> reverted
- drop ClientCAs -> master cert no longer trusted (red) -> reverted
* feat(node): accept mtls verify-mode + CA reveal endpoint (TDD)
- model.Node.TlsVerifyMode validator now accepts 'mtls'
- normalize() preserves mtls and requires the node scheme to be https
(fail closed), instead of clamping mtls back to verify
- NodeService.NodeMtlsCaCert + POST /panel/api/nodes/mtls/ca return this
panel's node-auth CA cert (public) to paste into a node, minting the CA
+ master client cert on first call
- endpoints.ts documents the new route (doc-sync test)
No model column added (enum is a string), so no migration/codegen.
* feat(node): node mTLS UI + trust-CA setter (TDD)
Backend:
- NodeService.SetNodeMtlsTrustCA + POST /panel/api/nodes/mtls/trustCA
store the CA this panel trusts for incoming node-API client certs
(validates PEM, empty clears); applied on next restart
- endpoints.ts + regenerated openapi.json document both mtls routes
Frontend:
- node form: 'mtls' TLS-verify option + setup hint (zod enum updated)
- Nodes page 'Node mTLS' card: copy this panel's CA, and paste/save the
trusted parent CA
- en-US i18n keys (other locales fall back to en-US)
Gates green: go build (native+windows), vet, go test ./...; frontend
typecheck, lint, vitest (541).
* style(node): gofmt web_mtls_test doc comment
* feat(node): hashed+zstd reconcile transport (TDD, negotiated, mixed-version safe)
Adds an integrity + compression envelope to node config pushes:
- internal/util/wirecodec: shared zstd codec (bomb-capped decode) +
SHA-256 hashing + the header/capability constants
- Remote.do(): always attaches X-Config-Sha256 of the uncompressed body;
zstd-compresses only when the node advertised support (learned from its
X-3x-Node-Caps response header) and the body is >=1KiB
- ConfigEnvelopeMiddleware on /panel/api: advertises the cap, decompresses
and verifies the hash (handler not invoked on mismatch) before binding
Mixed-version safe: old nodes never advertise the cap -> plain bodies;
the hash header is verify-if-present so any panel/node mix interoperates
(existing reconcile tests stay green). klauspost/compress promoted to a
direct dep. Hash-mismatch reject was mutation-checked (compare defeated
-> test red -> reverted).
* feat(node): per-node network throughput metrics (TDD)
The node status response already carries gopsutil netIO.up/down (summed
non-virtual interfaces), so no node-side change is needed:
- probe() parses netIO.up/down into HeartbeatPatch.NetUp/NetDown
- Node gains net_up/net_down columns (AutoMigrate); UpdateHeartbeat
persists them and appends netUp/netDown to the per-node metric history
- NodeMetricKeys whitelists netUp/netDown so the history endpoint serves them
- NodeHistoryPanel renders Net Up/Down sparklines (KB/s, no 0-100 clamp)
- regenerated frontend types + openapi.json for the new Node fields
* feat(node): move node mTLS controls into a toolbar button + modal
The Node mTLS panel was an always-visible card cluttering the nodes
page. Replace it with a 'Node mTLS' button beside 'Add node' that opens
a modal with the same copy-CA + trusted-parent-CA controls; the modal
closes on a successful save. No backend/i18n changes.
* i18n(node): translate mTLS + net-metrics keys for all locales
Adds the node mTLS strings (tlsMtls, mtlsFormHint, mtls.* dialog + the
saveMtls toast) and the netUp/netDown chart labels to all 12 non-English
catalogs (ar, es, fa, id, ja, pt, ru, tr, uk, vi, zh-CN, zh-TW), matching
each catalog's existing terminology. Technical tokens (mTLS/TLS/CA/API/
KB/s) kept verbatim.
* fix(node): address Copilot review on node-hardening PR
- setting_mtls: fail closed on a half-present CA/master-cert pair instead of
silently regenerating (which would rotate the CA and break fleet trust).
- config_envelope: reject non-zstd Content-Encoding on the envelope path
rather than hashing/forwarding a still-encoded body to the handler.
- node mTLS: support tokenless mTLS end-to-end — apiToken is now
required_unless tlsVerifyMode=mtls (model) with matching conditional
validation in NodeFormSchema, so the runtime allowance is actually reachable.
- NodesPage: add a catch block to onSaveTrustCa so save failures surface.
649 lines
19 KiB
Go
649 lines
19 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/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/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/email"
|
||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||
"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()
|
||
|
||
// 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
|
||
tgbotService tgbot.Tgbot
|
||
|
||
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. importDB restores a full SQLite
|
||
// backup that legitimately exceeds the cap, so it's 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, &s.settingService)
|
||
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)
|
||
|
||
s.index = controller.NewIndexController(g)
|
||
s.panel = controller.NewXUIController(g)
|
||
g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
|
||
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{})
|
||
})
|
||
|
||
// Add a catch-all route to handle undefined paths and return 404
|
||
engine.NoRoute(func(c *gin.Context) {
|
||
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"
|
||
cadenceClientIPScan = "@every 10s"
|
||
cadenceNodeHeartbeat = "@every 5s"
|
||
cadenceNodeTraffic = "@every 5s"
|
||
cadenceOutboundSub = "@every 5m"
|
||
cadenceCheckHash = "@every 2m"
|
||
// 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"
|
||
)
|
||
|
||
// 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) {
|
||
if restartXray {
|
||
err := s.xrayService.RestartXray(true)
|
||
if err != nil {
|
||
logger.Warning("start xray failed:", err)
|
||
}
|
||
}
|
||
// Check whether xray is running every second
|
||
s.cron.AddJob(cadenceXrayRunning, job.NewCheckXrayRunningJob())
|
||
|
||
// Check if xray needs to be restarted every 30 seconds
|
||
s.cron.AddFunc(cadenceXrayRestart, func() {
|
||
if s.xrayService.IsNeedRestartAndSetFalse() {
|
||
err := s.xrayService.RestartXray(false)
|
||
if err != nil {
|
||
logger.Error("restart xray failed:", err)
|
||
}
|
||
}
|
||
})
|
||
|
||
go func() {
|
||
time.Sleep(time.Second * 5)
|
||
s.cron.AddJob(cadenceXrayTraffic, job.NewXrayTrafficJob())
|
||
}()
|
||
|
||
// Reconcile mtproto (mtg) sidecars and scrape their traffic
|
||
mtJob := job.NewMtprotoJob()
|
||
s.cron.AddJob(cadenceMtproto, mtJob)
|
||
go mtJob.Run()
|
||
|
||
// check client ips from log file every 10 sec
|
||
s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
|
||
|
||
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())
|
||
|
||
// check client ips from log file every day
|
||
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||
s.cron.AddJob("@hourly", job.NewWarpIpJob())
|
||
|
||
// Inbound traffic reset jobs
|
||
// Run every hour
|
||
s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
|
||
// Run once a day, midnight
|
||
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
|
||
// Run once a week, midnight between Sat/Sun
|
||
s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
|
||
// Run once a month, midnight, first of month
|
||
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
|
||
|
||
// LDAP sync scheduling
|
||
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
|
||
runtime, err := s.settingService.GetLdapSyncCron()
|
||
if err != nil || runtime == "" {
|
||
runtime = "@every 1m"
|
||
}
|
||
j := job.NewLdapSyncJob()
|
||
// job has zero-value services with method receivers that read settings on demand
|
||
s.cron.AddJob(runtime, j)
|
||
}
|
||
|
||
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
|
||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||
if (err == nil) && (isTgbotenabled) {
|
||
runtime, err := s.settingService.GetTgbotRuntime()
|
||
if err != nil {
|
||
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
|
||
runtime = "@daily"
|
||
} else if strings.TrimSpace(runtime) == "" {
|
||
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
|
||
runtime = "@daily"
|
||
}
|
||
logger.Infof("Tg notify enabled,run at %s", runtime)
|
||
if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
|
||
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
|
||
}
|
||
|
||
// check for Telegram bot callback query hash storage reset
|
||
s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
|
||
}
|
||
|
||
// CPU monitor publishes cpu.high events; register it whenever any notifier
|
||
// (Telegram or Email) wants them, independent of the Telegram bot being on.
|
||
if s.cpuAlarmWanted() {
|
||
s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
|
||
}
|
||
}
|
||
|
||
// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
|
||
// alerts, so the minute-long blocking CPU sampler only runs when it's needed.
|
||
func (s *Server) cpuAlarmWanted() bool {
|
||
wants := func(events string, threshold int) bool {
|
||
if threshold <= 0 {
|
||
return false
|
||
}
|
||
for _, e := range strings.Split(events, ",") {
|
||
if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
||
events, _ := s.settingService.GetTgEnabledEvents()
|
||
cpu, _ := s.settingService.GetTgCpu()
|
||
if wants(events, cpu) {
|
||
return true
|
||
}
|
||
}
|
||
if on, _ := s.settingService.GetSmtpEnable(); on {
|
||
events, _ := s.settingService.GetSmtpEnabledEvents()
|
||
cpu, _ := s.settingService.GetSmtpCpu()
|
||
if wants(events, cpu) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
||
func (s *Server) Start() (err error) {
|
||
return s.start(true, true)
|
||
}
|
||
|
||
func (s *Server) StartPanelOnly() (err error) {
|
||
return s.start(false, true)
|
||
}
|
||
|
||
func (s *Server) start(restartXray bool, startTgBot 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()
|
||
|
||
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
|
||
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.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},
|
||
}
|
||
// 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 func() {
|
||
s.httpServer.Serve(listener)
|
||
}()
|
||
|
||
// Create event bus before startTask so jobs can use it
|
||
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
||
service.SetEventBus(s.bus)
|
||
job.EventBus = s.bus
|
||
tgbot.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(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// Register email subscriber (always — it checks smtpEnable at runtime)
|
||
emailService := email.NewEmailService(s.settingService)
|
||
emailSub := email.NewSubscriber(s.settingService, emailService)
|
||
s.bus.Subscribe("email-notifier", emailSub.HandleEvent)
|
||
|
||
// Wire email service to controller for test endpoint
|
||
controller.SetEmailService(emailService)
|
||
|
||
// Wire Telegram test function to controller
|
||
controller.SetTestTgFunc(func() error {
|
||
if !s.tgbotService.IsRunning() {
|
||
return fmt.Errorf("telegram bot is not running (check token and chat ID)")
|
||
}
|
||
if err := s.tgbotService.TestConnection(); err != nil {
|
||
return fmt.Errorf("telegram API test failed: %w", err)
|
||
}
|
||
s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
|
||
return nil
|
||
})
|
||
|
||
s.startTask(restartXray)
|
||
|
||
if startTgBot {
|
||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||
if (err == nil) && (isTgbotenabled) {
|
||
tgBot := s.tgbotService.NewTgbot()
|
||
tgBot.Start(i18nFS)
|
||
// Subscribe Telegram notifications for event bus
|
||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
|
||
func (s *Server) Stop() error {
|
||
return s.stop(true, true)
|
||
}
|
||
|
||
func (s *Server) StopPanelOnly() error {
|
||
return s.stop(false, true)
|
||
}
|
||
|
||
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||
s.cancel()
|
||
if stopXray {
|
||
s.xrayService.StopXray()
|
||
mtproto.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()
|
||
}
|
||
if stopTgBot && s.tgbotService.IsRunning() {
|
||
s.tgbotService.Stop()
|
||
}
|
||
// 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)
|
||
}
|