mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 21:04:32 +00:00
* feat(nodes): add stable panel GUID identity (multi-hop phase 0) Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983). Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on. Refs #4983 * feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1) Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched. The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id. Refs #4983 * feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2) Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983). xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly. Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983 Refs #4983 * feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a) Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983). The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next. Refs #4983 * feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b) The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983). Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983 Refs #4983 * i18n(nodes): translate subNode/subNodeTip across all locales Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation. Refs #4983
423 lines
13 KiB
Go
423 lines
13 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/database"
|
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/web/entity"
|
|
"github.com/mhsanaei/3x-ui/v3/web/global"
|
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/web/websocket"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
|
|
|
// ServerController handles server management and status-related operations.
|
|
type ServerController struct {
|
|
BaseController
|
|
|
|
serverService service.ServerService
|
|
settingService service.SettingService
|
|
panelService service.PanelService
|
|
xrayMetricsService service.XrayMetricsService
|
|
}
|
|
|
|
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
|
func NewServerController(g *gin.RouterGroup) *ServerController {
|
|
a := &ServerController{}
|
|
service.RestoreSystemMetrics()
|
|
a.initRouter(g)
|
|
a.startTask()
|
|
return a
|
|
}
|
|
|
|
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
|
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|
|
|
g.GET("/status", a.status)
|
|
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
|
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
|
|
g.GET("/xrayMetricsState", a.getXrayMetricsState)
|
|
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
|
|
g.GET("/xrayObservatory", a.getXrayObservatory)
|
|
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
|
|
g.GET("/getXrayVersion", a.getXrayVersion)
|
|
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
|
g.GET("/getConfigJson", a.getConfigJson)
|
|
g.GET("/getDb", a.getDb)
|
|
g.GET("/getMigration", a.getMigration)
|
|
g.GET("/getNewUUID", a.getNewUUID)
|
|
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
|
g.GET("/descendants", a.descendants)
|
|
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
|
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
|
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
|
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
|
|
|
|
g.POST("/stopXrayService", a.stopXrayService)
|
|
g.POST("/restartXrayService", a.restartXrayService)
|
|
g.POST("/installXray/:version", a.installXray)
|
|
g.POST("/updatePanel", a.updatePanel)
|
|
g.POST("/updateGeofile", a.updateGeofile)
|
|
g.POST("/updateGeofile/:fileName", a.updateGeofile)
|
|
g.POST("/logs/:count", a.getLogs)
|
|
g.POST("/xraylogs/:count", a.getXrayLogs)
|
|
g.POST("/importDB", a.importDB)
|
|
g.POST("/getNewEchCert", a.getNewEchCert)
|
|
}
|
|
|
|
// 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() {
|
|
c := global.GetWebServer().GetCron()
|
|
c.AddFunc("@every 2s", func() {
|
|
status := a.serverService.RefreshStatus()
|
|
if status == nil {
|
|
return
|
|
}
|
|
a.xrayMetricsService.Sample(time.Now())
|
|
websocket.BroadcastStatus(status)
|
|
})
|
|
c.AddFunc("@every 1m", func() {
|
|
if err := service.PersistSystemMetrics(); err != nil {
|
|
logger.Warning("persist system metrics failed:", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// status returns the current server status information.
|
|
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
|
|
|
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) {
|
|
bucket, ok := parseHistoryBucket(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
|
|
}
|
|
|
|
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
|
// system metric (cpu, mem, netUp, netDown, online, load1/5/15). The
|
|
// SystemHistoryModal calls one endpoint per active tab.
|
|
func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
|
metric := c.Param("metric")
|
|
if !slices.Contains(service.SystemMetricKeys, metric) {
|
|
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
|
return
|
|
}
|
|
bucket, ok := parseHistoryBucket(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
|
}
|
|
|
|
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
|
|
jsonObj(c, a.xrayMetricsService.State(), nil)
|
|
}
|
|
|
|
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
|
|
metric := c.Param("metric")
|
|
if !slices.Contains(service.XrayMetricKeys, metric) {
|
|
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
|
return
|
|
}
|
|
bucket, ok := parseHistoryBucket(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
|
}
|
|
|
|
func (a *ServerController) getXrayObservatory(c *gin.Context) {
|
|
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
|
|
}
|
|
|
|
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
|
|
tag := c.Param("tag")
|
|
if !a.xrayMetricsService.HasObservatoryTag(tag) {
|
|
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
|
return
|
|
}
|
|
bucket, ok := parseHistoryBucket(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
|
}
|
|
|
|
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
|
versions, err := a.serverService.GetXrayVersionsCached()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "getVersion"), err)
|
|
return
|
|
}
|
|
jsonObj(c, versions, nil)
|
|
}
|
|
|
|
// getPanelUpdateInfo retrieves the current and latest panel version.
|
|
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
|
info, err := a.panelService.GetUpdateInfo()
|
|
if err != nil {
|
|
logger.Debug("panel update check failed:", err)
|
|
c.JSON(http.StatusOK, entity.Msg{Success: false})
|
|
return
|
|
}
|
|
jsonObj(c, info, nil)
|
|
}
|
|
|
|
// installXray installs or updates Xray to the specified version.
|
|
func (a *ServerController) installXray(c *gin.Context) {
|
|
version := c.Param("version")
|
|
err := a.serverService.UpdateXray(version)
|
|
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
|
|
}
|
|
|
|
// updatePanel starts a panel self-update to the latest release.
|
|
func (a *ServerController) updatePanel(c *gin.Context) {
|
|
err := a.panelService.StartUpdate()
|
|
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
|
|
}
|
|
|
|
// updateGeofile updates the specified geo file for Xray.
|
|
func (a *ServerController) updateGeofile(c *gin.Context) {
|
|
fileName := c.Param("fileName")
|
|
|
|
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
|
|
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
|
|
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
|
|
return
|
|
}
|
|
|
|
err := a.serverService.UpdateGeofile(fileName)
|
|
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
|
|
}
|
|
|
|
// stopXrayService stops the Xray service.
|
|
func (a *ServerController) stopXrayService(c *gin.Context) {
|
|
err := a.serverService.StopXrayService()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
|
|
websocket.BroadcastXrayState("error", err.Error())
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
|
websocket.BroadcastXrayState("stop", "")
|
|
websocket.BroadcastNotification(
|
|
I18nWeb(c, "pages.xray.stopSuccess"),
|
|
"Xray service has been stopped",
|
|
"warning",
|
|
)
|
|
}
|
|
|
|
// restartXrayService restarts the Xray service.
|
|
func (a *ServerController) restartXrayService(c *gin.Context) {
|
|
err := a.serverService.RestartXrayService()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
|
|
websocket.BroadcastXrayState("error", err.Error())
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
|
websocket.BroadcastXrayState("running", "")
|
|
websocket.BroadcastNotification(
|
|
I18nWeb(c, "pages.xray.restartSuccess"),
|
|
"Xray service has been restarted successfully",
|
|
"success",
|
|
)
|
|
}
|
|
|
|
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
|
func (a *ServerController) getLogs(c *gin.Context) {
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
// getConfigJson retrieves the Xray configuration as JSON.
|
|
func (a *ServerController) getConfigJson(c *gin.Context) {
|
|
configJson, err := a.serverService.GetConfigJson()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.index.getConfigError"), err)
|
|
return
|
|
}
|
|
jsonObj(c, configJson, nil)
|
|
}
|
|
|
|
// getDb downloads the database file.
|
|
func (a *ServerController) getDb(c *gin.Context) {
|
|
db, err := a.serverService.GetDb()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
|
return
|
|
}
|
|
|
|
filename := "x-ui.db"
|
|
if database.IsPostgres() {
|
|
filename = "x-ui.dump"
|
|
}
|
|
if !filenameRegex.MatchString(filename) {
|
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/octet-stream")
|
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
|
c.Writer.Write(db)
|
|
}
|
|
|
|
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
|
|
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
|
|
func (a *ServerController) getMigration(c *gin.Context) {
|
|
data, filename, err := a.serverService.GetMigration()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
|
return
|
|
}
|
|
if !filenameRegex.MatchString(filename) {
|
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/octet-stream")
|
|
c.Header("Content-Disposition", "attachment; filename="+filename)
|
|
c.Writer.Write(data)
|
|
}
|
|
|
|
// importDB imports a database file and restarts the Xray service.
|
|
func (a *ServerController) importDB(c *gin.Context) {
|
|
file, _, err := c.Request.FormFile("db")
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
if err := a.serverService.ImportDB(file); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
|
return
|
|
}
|
|
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
|
}
|
|
|
|
// descendants publishes read-only summaries of the nodes this panel manages so
|
|
// a parent panel can surface them as transitive sub-nodes in a chained
|
|
// topology. Called by the parent via the node's API token (#4983).
|
|
func (a *ServerController) descendants(c *gin.Context) {
|
|
data, err := (&service.NodeService{}).LocalDescendants()
|
|
jsonObj(c, data, err)
|
|
}
|
|
|
|
// getWebCertFiles returns this panel's own web TLS certificate and key file
|
|
// paths. The central panel calls it on a node (via the node's API token) so
|
|
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
|
|
// on the node's filesystem instead of the central panel's — see issue #4854.
|
|
func (a *ServerController) getWebCertFiles(c *gin.Context) {
|
|
certFile, err := a.settingService.GetCertFile()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
keyFile, err := a.settingService.GetKeyFile()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil)
|
|
}
|
|
|
|
// getNewX25519Cert generates a new X25519 certificate.
|
|
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
|
cert, err := a.serverService.GetNewX25519Cert()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewX25519CertError"), err)
|
|
return
|
|
}
|
|
jsonObj(c, cert, nil)
|
|
}
|
|
|
|
// getNewmldsa65 generates a new ML-DSA-65 key.
|
|
func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
|
cert, err := a.serverService.GetNewmldsa65()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewmldsa65Error"), err)
|
|
return
|
|
}
|
|
jsonObj(c, cert, nil)
|
|
}
|
|
|
|
// getNewEchCert generates a new ECH certificate for the given SNI.
|
|
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
|
cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
|
|
if err != nil {
|
|
jsonMsg(c, "get ech certificate", err)
|
|
return
|
|
}
|
|
jsonObj(c, cert, nil)
|
|
}
|
|
|
|
// getNewVlessEnc generates a new VLESS encryption key.
|
|
func (a *ServerController) getNewVlessEnc(c *gin.Context) {
|
|
out, err := a.serverService.GetNewVlessEnc()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewVlessEncError"), err)
|
|
return
|
|
}
|
|
jsonObj(c, out, nil)
|
|
}
|
|
|
|
// getNewUUID generates a new UUID.
|
|
func (a *ServerController) getNewUUID(c *gin.Context) {
|
|
uuidResp, err := a.serverService.GetNewUUID()
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to generate UUID", err)
|
|
return
|
|
}
|
|
jsonObj(c, uuidResp, nil)
|
|
}
|
|
|
|
// getNewmlkem768 generates a new ML-KEM-768 key.
|
|
func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
|
out, err := a.serverService.GetNewmlkem768()
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to generate mlkem768 keys", err)
|
|
return
|
|
}
|
|
jsonObj(c, out, nil)
|
|
}
|