mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
refactor(server): move cached state and helpers into ServerService
ServerController had grown to hold its own status cache, version-list TTL cache, history-bucket whitelist, and the loop that drove all three — concerns that belong in the service layer. Pull them out: - lastStatus + the @2s refresh become ServerService.RefreshStatus and ServerService.LastStatus; the controller's cron now just orchestrates the cross-service side effects (xrayMetrics sample, websocket broadcast). - The 15-minute Xray-versions cache (with stale-on-error fallback) moves into ServerService.GetXrayVersionsCached, collapsing the controller handler to a single call. - The freedom/blackhole outbound-tag walk used by /xraylogs becomes ServerService.GetDefaultLogOutboundTags. - The allowed-history-bucket whitelist moves to package-level service.IsAllowedHistoryBucket, so both NodeController and ServerController validate against the same list. Net result: web/controller/server.go drops from 458 to 365 lines and contains only HTTP wiring + presentation-y side effects. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1f4e2707a0
commit
e500c04877
4 changed files with 170 additions and 139 deletions
|
|
@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
||||
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,6 @@ type ServerController struct {
|
|||
settingService service.SettingService
|
||||
panelService service.PanelService
|
||||
xrayMetricsService service.XrayMetricsService
|
||||
|
||||
lastStatus *service.Status
|
||||
|
||||
lastVersions []string
|
||||
lastGetVersionsTime int64 // unix seconds
|
||||
}
|
||||
|
||||
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
||||
|
|
@ -74,63 +69,43 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||
}
|
||||
|
||||
// refreshStatus updates the cached server status and collects time-series
|
||||
// metrics. CPU/Mem/Net/Online/Load are all written in one call so the
|
||||
// SystemHistoryModal's tabs share an identical x-axis.
|
||||
func (a *ServerController) refreshStatus() {
|
||||
a.lastStatus = a.serverService.GetStatus(a.lastStatus)
|
||||
if a.lastStatus != nil {
|
||||
now := time.Now()
|
||||
a.serverService.AppendStatusSample(now, a.lastStatus)
|
||||
a.xrayMetricsService.Sample(now)
|
||||
// Broadcast status update via WebSocket
|
||||
websocket.BroadcastStatus(a.lastStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// startTask initiates background tasks for continuous status monitoring.
|
||||
// startTask registers the @2s ticker that refreshes server status, samples
|
||||
// xray metrics, and pushes the new snapshot to all websocket subscribers.
|
||||
// State + sampling live in ServerService; the controller only orchestrates
|
||||
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
|
||||
func (a *ServerController) startTask() {
|
||||
webServer := global.GetWebServer()
|
||||
c := webServer.GetCron()
|
||||
c := global.GetWebServer().GetCron()
|
||||
c.AddFunc("@every 2s", func() {
|
||||
// Always refresh to keep CPU history collected continuously.
|
||||
// Sampling is lightweight and capped to ~6 hours in memory.
|
||||
a.refreshStatus()
|
||||
status := a.serverService.RefreshStatus()
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
a.xrayMetricsService.Sample(time.Now())
|
||||
websocket.BroadcastStatus(status)
|
||||
})
|
||||
}
|
||||
|
||||
// status returns the current server status information.
|
||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
|
||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
||||
|
||||
// allowedHistoryBuckets is the bucket-second whitelist shared by both
|
||||
// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it
|
||||
// prevents callers from triggering arbitrary aggregation work and keeps
|
||||
// the front-end's bucket selector self-documenting.
|
||||
var allowedHistoryBuckets = map[int]bool{
|
||||
2: true, // Real-time view
|
||||
30: true, // 30s intervals
|
||||
60: true, // 1m intervals
|
||||
120: true, // 2m intervals
|
||||
180: true, // 3m intervals
|
||||
300: true, // 5m intervals
|
||||
func parseHistoryBucket(c *gin.Context) (int, bool) {
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return 0, false
|
||||
}
|
||||
return bucket, true
|
||||
}
|
||||
|
||||
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
||||
// Kept for back-compat; new callers should use /history/cpu/:bucket which
|
||||
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
|
||||
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||
bucketStr := c.Param("bucket")
|
||||
bucket, err := strconv.Atoi(bucketStr)
|
||||
if err != nil || bucket <= 0 {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !allowedHistoryBuckets[bucket] {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return
|
||||
}
|
||||
points := a.serverService.AggregateCpuHistory(bucket, 60)
|
||||
jsonObj(c, points, nil)
|
||||
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
|
||||
}
|
||||
|
||||
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
||||
|
|
@ -142,9 +117,8 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
|||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
||||
|
|
@ -160,9 +134,8 @@ func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
|
|||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
||||
|
|
@ -178,37 +151,19 @@ func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
|
|||
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||
const cacheTTLSeconds = 15 * 60
|
||||
|
||||
now := time.Now().Unix()
|
||||
if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
|
||||
jsonObj(c, a.lastVersions, nil)
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := a.serverService.GetXrayVersions()
|
||||
versions, err := a.serverService.GetXrayVersionsCached()
|
||||
if err != nil {
|
||||
if a.lastVersions != nil {
|
||||
logger.Warning("getXrayVersion failed; serving cached list:", err)
|
||||
jsonObj(c, a.lastVersions, nil)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "getVersion"), err)
|
||||
return
|
||||
}
|
||||
|
||||
a.lastVersions = versions
|
||||
a.lastGetVersionsTime = now
|
||||
|
||||
jsonObj(c, versions, nil)
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) {
|
|||
func (a *ServerController) updateGeofile(c *gin.Context) {
|
||||
fileName := c.Param("fileName")
|
||||
|
||||
// Validate the filename for security (prevent path traversal attacks)
|
||||
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
|
||||
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
|
||||
|
|
@ -287,55 +241,22 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
|
|||
|
||||
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
||||
func (a *ServerController) getLogs(c *gin.Context) {
|
||||
count := c.Param("count")
|
||||
level := c.PostForm("level")
|
||||
syslog := c.PostForm("syslog")
|
||||
logs := a.serverService.GetLogs(count, level, syslog)
|
||||
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
|
||||
jsonObj(c, logs, nil)
|
||||
}
|
||||
|
||||
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
|
||||
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
||||
count := c.Param("count")
|
||||
filter := c.PostForm("filter")
|
||||
showDirect := c.PostForm("showDirect")
|
||||
showBlocked := c.PostForm("showBlocked")
|
||||
showProxy := c.PostForm("showProxy")
|
||||
|
||||
var freedoms []string
|
||||
var blackholes []string
|
||||
|
||||
//getting tags for freedom and blackhole outbounds
|
||||
config, err := a.settingService.GetDefaultXrayConfig()
|
||||
if err == nil && config != nil {
|
||||
if cfgMap, ok := config.(map[string]any); ok {
|
||||
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
||||
for _, outbound := range outbounds {
|
||||
if obMap, ok := outbound.(map[string]any); ok {
|
||||
switch obMap["protocol"] {
|
||||
case "freedom":
|
||||
if tag, ok := obMap["tag"].(string); ok {
|
||||
freedoms = append(freedoms, tag)
|
||||
}
|
||||
case "blackhole":
|
||||
if tag, ok := obMap["tag"].(string); ok {
|
||||
blackholes = append(blackholes, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(freedoms) == 0 {
|
||||
freedoms = []string{"direct"}
|
||||
}
|
||||
if len(blackholes) == 0 {
|
||||
blackholes = []string{"blocked"}
|
||||
}
|
||||
|
||||
logs := a.serverService.GetXrayLogs(count, filter, showDirect, showBlocked, showProxy, freedoms, blackholes)
|
||||
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
|
||||
logs := a.serverService.GetXrayLogs(
|
||||
c.Param("count"),
|
||||
c.PostForm("filter"),
|
||||
c.PostForm("showDirect"),
|
||||
c.PostForm("showBlocked"),
|
||||
c.PostForm("showProxy"),
|
||||
freedoms,
|
||||
blackholes,
|
||||
)
|
||||
jsonObj(c, logs, nil)
|
||||
}
|
||||
|
||||
|
|
@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) {
|
|||
}
|
||||
|
||||
filename := "x-ui.db"
|
||||
|
||||
if !isValidFilename(filename) {
|
||||
if !filenameRegex.MatchString(filename) {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||
return
|
||||
}
|
||||
|
||||
// Set the headers for the response
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
|
||||
// Write the file contents to the response
|
||||
c.Writer.Write(db)
|
||||
}
|
||||
|
||||
func isValidFilename(filename string) bool {
|
||||
// Validate that the filename only contains allowed characters
|
||||
return filenameRegex.MatchString(filename)
|
||||
}
|
||||
|
||||
// importDB imports a database file and restarts the Xray service.
|
||||
func (a *ServerController) importDB(c *gin.Context) {
|
||||
// Get the file from the request body
|
||||
file, _, err := c.Request.FormFile("db")
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
err = a.serverService.ImportDB(file)
|
||||
if err != nil {
|
||||
if err := a.serverService.ImportDB(file); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
|
|
@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
|||
|
||||
// getNewEchCert generates a new ECH certificate for the given SNI.
|
||||
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
||||
sni := c.PostForm("sni")
|
||||
cert, err := a.serverService.GetNewEchCert(sni)
|
||||
cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
|
||||
if err != nil {
|
||||
jsonMsg(c, "get ech certificate", err)
|
||||
return
|
||||
|
|
@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
|
|||
jsonMsg(c, "Failed to generate UUID", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonObj(c, uuidResp, nil)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
|||
}
|
||||
|
||||
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
|
||||
// fed by ServerController.refreshStatus every 2s. nodeMetrics holds
|
||||
// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
|
||||
// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
|
||||
// process-local — survival across panel restart is not required.
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ type Release struct {
|
|||
type ServerService struct {
|
||||
xrayService XrayService
|
||||
inboundService InboundService
|
||||
settingService SettingService
|
||||
cachedIPv4 string
|
||||
cachedIPv6 string
|
||||
noIPv6 bool
|
||||
|
|
@ -115,6 +116,128 @@ type ServerService struct {
|
|||
emaCPU float64
|
||||
cachedCpuSpeedMhz float64
|
||||
lastCpuInfoAttempt time.Time
|
||||
|
||||
lastStatusMu sync.RWMutex
|
||||
lastStatus *Status
|
||||
|
||||
versionsCacheMu sync.Mutex
|
||||
versionsCache *cachedXrayVersions
|
||||
}
|
||||
|
||||
type cachedXrayVersions struct {
|
||||
versions []string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
// xrayVersionsCacheTTL bounds how often /getXrayVersion hits GitHub. The list
|
||||
// is purely informational (rendered in the "switch Xray version" picker) so a
|
||||
// quarter-hour staleness window is fine and saves the API budget.
|
||||
const xrayVersionsCacheTTL = 15 * time.Minute
|
||||
|
||||
// allowedHistoryBuckets is the bucket-second whitelist for time-series
|
||||
// aggregation endpoints (server + node metrics). Restricting it prevents
|
||||
// callers from triggering arbitrary aggregation work and keeps the
|
||||
// frontend's bucket selector self-documenting.
|
||||
var allowedHistoryBuckets = map[int]bool{
|
||||
2: true, // Real-time view
|
||||
30: true, // 30s intervals
|
||||
60: true, // 1m intervals
|
||||
120: true, // 2m intervals
|
||||
180: true, // 3m intervals
|
||||
300: true, // 5m intervals
|
||||
}
|
||||
|
||||
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
|
||||
// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
|
||||
// /server/xrayObservatoryHistory, and /nodes/history.
|
||||
func IsAllowedHistoryBucket(bucketSeconds int) bool {
|
||||
return allowedHistoryBuckets[bucketSeconds]
|
||||
}
|
||||
|
||||
// LastStatus returns the most recent Status snapshot collected by
|
||||
// RefreshStatus. Safe for concurrent readers.
|
||||
func (s *ServerService) LastStatus() *Status {
|
||||
s.lastStatusMu.RLock()
|
||||
defer s.lastStatusMu.RUnlock()
|
||||
return s.lastStatus
|
||||
}
|
||||
|
||||
// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
|
||||
// appends it to the system-metrics time series. Returns the new snapshot (may
|
||||
// be nil if collection failed). Called by the background ticker; the caller is
|
||||
// responsible for any side effects (websocket broadcast, xray metrics sample).
|
||||
func (s *ServerService) RefreshStatus() *Status {
|
||||
next := s.GetStatus(s.LastStatus())
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
s.lastStatusMu.Lock()
|
||||
s.lastStatus = next
|
||||
s.lastStatusMu.Unlock()
|
||||
s.AppendStatusSample(time.Now(), next)
|
||||
return next
|
||||
}
|
||||
|
||||
// GetXrayVersionsCached wraps GetXrayVersions with a TTL cache. On fetch
|
||||
// failure we serve the last successful list (if any) so the UI doesn't go
|
||||
// blank during a GitHub API hiccup; if there's no cache at all the underlying
|
||||
// error is surfaced.
|
||||
func (s *ServerService) GetXrayVersionsCached() ([]string, error) {
|
||||
s.versionsCacheMu.Lock()
|
||||
cache := s.versionsCache
|
||||
s.versionsCacheMu.Unlock()
|
||||
if cache != nil && time.Since(cache.fetchedAt) <= xrayVersionsCacheTTL {
|
||||
return cache.versions, nil
|
||||
}
|
||||
versions, err := s.GetXrayVersions()
|
||||
if err != nil {
|
||||
if cache != nil {
|
||||
logger.Warning("GetXrayVersionsCached: serving stale list:", err)
|
||||
return cache.versions, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.versionsCacheMu.Lock()
|
||||
s.versionsCache = &cachedXrayVersions{versions: versions, fetchedAt: time.Now()}
|
||||
s.versionsCacheMu.Unlock()
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// GetDefaultLogOutboundTags scans the default Xray config for freedom and
|
||||
// blackhole outbound tags so /getXrayLogs can colour-code log lines without
|
||||
// the controller re-doing the JSON walk. Falls back to the historical
|
||||
// "direct"/"blocked" defaults when the config can't be read.
|
||||
func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
|
||||
config, err := s.settingService.GetDefaultXrayConfig()
|
||||
if err == nil && config != nil {
|
||||
if cfgMap, ok := config.(map[string]any); ok {
|
||||
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
||||
for _, outbound := range outbounds {
|
||||
obMap, ok := outbound.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tag, _ := obMap["tag"].(string)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
switch obMap["protocol"] {
|
||||
case "freedom":
|
||||
freedoms = append(freedoms, tag)
|
||||
case "blackhole":
|
||||
blackholes = append(blackholes, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(freedoms) == 0 {
|
||||
freedoms = []string{"direct"}
|
||||
}
|
||||
if len(blackholes) == 0 {
|
||||
blackholes = []string{"blocked"}
|
||||
}
|
||||
return freedoms, blackholes
|
||||
}
|
||||
|
||||
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
|
||||
|
|
@ -385,8 +508,8 @@ func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
|
|||
|
||||
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
|
||||
// percent, network throughput (bytes/s), online client count, and the three
|
||||
// load averages. Called by ServerController.refreshStatus on the same @2s
|
||||
// cadence as AppendCpuSample, so all series stay aligned.
|
||||
// load averages. Called by RefreshStatus on the same @2s cadence as
|
||||
// AppendCpuSample, so all series stay aligned.
|
||||
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
||||
if status == nil {
|
||||
return
|
||||
|
|
|
|||
Loading…
Reference in a new issue