From e500c048772dd0e0531dd0f0414a042492f803e0 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 17 May 2026 18:17:50 +0200 Subject: [PATCH] refactor(server): move cached state and helpers into ServerService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/controller/node.go | 2 +- web/controller/server.go | 178 ++++++++-------------------------- web/service/metric_history.go | 2 +- web/service/server.go | 127 +++++++++++++++++++++++- 4 files changed, 170 insertions(+), 139 deletions(-) diff --git a/web/controller/node.go b/web/controller/node.go index ab0127d2..d12db5f8 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -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 } diff --git a/web/controller/server.go b/web/controller/server.go index 4d5aa356..a2326720 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -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) } diff --git a/web/service/metric_history.go b/web/service/metric_history.go index 5905b678..42d2cb82 100644 --- a/web/service/metric_history.go +++ b/web/service/metric_history.go @@ -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 ( diff --git a/web/service/server.go b/web/service/server.go index 262a6183..e6e7bccb 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -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