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:
MHSanaei 2026-05-17 18:17:50 +02:00
parent 1f4e2707a0
commit e500c04877
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 170 additions and 139 deletions

View file

@ -178,7 +178,7 @@ func (a *NodeController) history(c *gin.Context) {
return return
} }
bucket, err := strconv.Atoi(c.Param("bucket")) 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")) jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return return
} }

View file

@ -27,11 +27,6 @@ type ServerController struct {
settingService service.SettingService settingService service.SettingService
panelService service.PanelService panelService service.PanelService
xrayMetricsService service.XrayMetricsService xrayMetricsService service.XrayMetricsService
lastStatus *service.Status
lastVersions []string
lastGetVersionsTime int64 // unix seconds
} }
// NewServerController creates a new ServerController, initializes routes, and starts background tasks. // 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) g.POST("/getNewEchCert", a.getNewEchCert)
} }
// refreshStatus updates the cached server status and collects time-series // startTask registers the @2s ticker that refreshes server status, samples
// metrics. CPU/Mem/Net/Online/Load are all written in one call so the // xray metrics, and pushes the new snapshot to all websocket subscribers.
// SystemHistoryModal's tabs share an identical x-axis. // State + sampling live in ServerService; the controller only orchestrates
func (a *ServerController) refreshStatus() { // the cross-service side effects (xrayMetrics sample + websocket broadcast).
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.
func (a *ServerController) startTask() { func (a *ServerController) startTask() {
webServer := global.GetWebServer() c := global.GetWebServer().GetCron()
c := webServer.GetCron()
c.AddFunc("@every 2s", func() { c.AddFunc("@every 2s", func() {
// Always refresh to keep CPU history collected continuously. status := a.serverService.RefreshStatus()
// Sampling is lightweight and capped to ~6 hours in memory. if status == nil {
a.refreshStatus() return
}
a.xrayMetricsService.Sample(time.Now())
websocket.BroadcastStatus(status)
}) })
} }
// status returns the current server status information. // 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 func parseHistoryBucket(c *gin.Context) (int, bool) {
// /cpuHistory/:bucket and /history/:metric/:bucket. Restricting it bucket, err := strconv.Atoi(c.Param("bucket"))
// prevents callers from triggering arbitrary aggregation work and keeps if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
// the front-end's bucket selector self-documenting. jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
var allowedHistoryBuckets = map[int]bool{ return 0, false
2: true, // Real-time view }
30: true, // 30s intervals return bucket, true
60: true, // 1m intervals
120: true, // 2m intervals
180: true, // 3m intervals
300: true, // 5m intervals
} }
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket. // getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
// Kept for back-compat; new callers should use /history/cpu/:bucket which // Kept for back-compat; new callers should use /history/cpu/:bucket which
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}. // returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) { func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
bucketStr := c.Param("bucket") bucket, ok := parseHistoryBucket(c)
bucket, err := strconv.Atoi(bucketStr) if !ok {
if err != nil || bucket <= 0 {
jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
return return
} }
if !allowedHistoryBuckets[bucket] { jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
points := a.serverService.AggregateCpuHistory(bucket, 60)
jsonObj(c, points, nil)
} }
// getMetricHistoryBucket returns up to 60 buckets of history for a single // 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")) jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return return
} }
bucket, err := strconv.Atoi(c.Param("bucket")) bucket, ok := parseHistoryBucket(c)
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { if !ok {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return return
} }
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil) 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")) jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return return
} }
bucket, err := strconv.Atoi(c.Param("bucket")) bucket, ok := parseHistoryBucket(c)
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { if !ok {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return return
} }
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil) 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")) jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
return return
} }
bucket, err := strconv.Atoi(c.Param("bucket")) bucket, ok := parseHistoryBucket(c)
if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { if !ok {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return return
} }
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil) jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
} }
func (a *ServerController) getXrayVersion(c *gin.Context) { func (a *ServerController) getXrayVersion(c *gin.Context) {
const cacheTTLSeconds = 15 * 60 versions, err := a.serverService.GetXrayVersionsCached()
now := time.Now().Unix()
if a.lastVersions != nil && now-a.lastGetVersionsTime <= cacheTTLSeconds {
jsonObj(c, a.lastVersions, nil)
return
}
versions, err := a.serverService.GetXrayVersions()
if err != nil { 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) jsonMsg(c, I18nWeb(c, "getVersion"), err)
return return
} }
a.lastVersions = versions
a.lastGetVersionsTime = now
jsonObj(c, versions, nil) jsonObj(c, versions, nil)
} }
@ -240,7 +195,6 @@ func (a *ServerController) updatePanel(c *gin.Context) {
func (a *ServerController) updateGeofile(c *gin.Context) { func (a *ServerController) updateGeofile(c *gin.Context) {
fileName := c.Param("fileName") fileName := c.Param("fileName")
// Validate the filename for security (prevent path traversal attacks)
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) { if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns")) 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. // getLogs retrieves the application logs based on count, level, and syslog filters.
func (a *ServerController) getLogs(c *gin.Context) { func (a *ServerController) getLogs(c *gin.Context) {
count := c.Param("count") logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
level := c.PostForm("level")
syslog := c.PostForm("syslog")
logs := a.serverService.GetLogs(count, level, syslog)
jsonObj(c, logs, nil) jsonObj(c, logs, nil)
} }
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic. // getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
func (a *ServerController) getXrayLogs(c *gin.Context) { func (a *ServerController) getXrayLogs(c *gin.Context) {
count := c.Param("count") freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
filter := c.PostForm("filter") logs := a.serverService.GetXrayLogs(
showDirect := c.PostForm("showDirect") c.Param("count"),
showBlocked := c.PostForm("showBlocked") c.PostForm("filter"),
showProxy := c.PostForm("showProxy") c.PostForm("showDirect"),
c.PostForm("showBlocked"),
var freedoms []string c.PostForm("showProxy"),
var blackholes []string freedoms,
blackholes,
//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)
jsonObj(c, logs, nil) jsonObj(c, logs, nil)
} }
@ -358,36 +279,25 @@ func (a *ServerController) getDb(c *gin.Context) {
} }
filename := "x-ui.db" filename := "x-ui.db"
if !filenameRegex.MatchString(filename) {
if !isValidFilename(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename")) c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return return
} }
// Set the headers for the response
c.Header("Content-Type", "application/octet-stream") c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename) c.Header("Content-Disposition", "attachment; filename="+filename)
// Write the file contents to the response
c.Writer.Write(db) 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. // importDB imports a database file and restarts the Xray service.
func (a *ServerController) importDB(c *gin.Context) { func (a *ServerController) importDB(c *gin.Context) {
// Get the file from the request body
file, _, err := c.Request.FormFile("db") file, _, err := c.Request.FormFile("db")
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err) jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
return return
} }
defer file.Close() defer file.Close()
err = a.serverService.ImportDB(file) if err := a.serverService.ImportDB(file); err != nil {
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err) jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
return return
} }
@ -416,8 +326,7 @@ func (a *ServerController) getNewmldsa65(c *gin.Context) {
// getNewEchCert generates a new ECH certificate for the given SNI. // getNewEchCert generates a new ECH certificate for the given SNI.
func (a *ServerController) getNewEchCert(c *gin.Context) { func (a *ServerController) getNewEchCert(c *gin.Context) {
sni := c.PostForm("sni") cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
cert, err := a.serverService.GetNewEchCert(sni)
if err != nil { if err != nil {
jsonMsg(c, "get ech certificate", err) jsonMsg(c, "get ech certificate", err)
return return
@ -442,7 +351,6 @@ func (a *ServerController) getNewUUID(c *gin.Context) {
jsonMsg(c, "Failed to generate UUID", err) jsonMsg(c, "Failed to generate UUID", err)
return return
} }
jsonObj(c, uuidResp, nil) jsonObj(c, uuidResp, nil)
} }

View file

@ -124,7 +124,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
} }
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.) // 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 // per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
// process-local — survival across panel restart is not required. // process-local — survival across panel restart is not required.
var ( var (

View file

@ -105,6 +105,7 @@ type Release struct {
type ServerService struct { type ServerService struct {
xrayService XrayService xrayService XrayService
inboundService InboundService inboundService InboundService
settingService SettingService
cachedIPv4 string cachedIPv4 string
cachedIPv6 string cachedIPv6 string
noIPv6 bool noIPv6 bool
@ -115,6 +116,128 @@ type ServerService struct {
emaCPU float64 emaCPU float64
cachedCpuSpeedMhz float64 cachedCpuSpeedMhz float64
lastCpuInfoAttempt time.Time 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. // 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 // AppendStatusSample writes one tick of every metric we keep — CPU, memory
// percent, network throughput (bytes/s), online client count, and the three // percent, network throughput (bytes/s), online client count, and the three
// load averages. Called by ServerController.refreshStatus on the same @2s // load averages. Called by RefreshStatus on the same @2s cadence as
// cadence as AppendCpuSample, so all series stay aligned. // AppendCpuSample, so all series stay aligned.
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) { func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
if status == nil { if status == nil {
return return