mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 17:39:35 +00:00
Two PostgreSQL gaps on the panel:
1. x-ui setting and other CLI subcommands read XUI_DB_TYPE/XUI_DB_DSN from
the process environment, which systemd injects via EnvironmentFile but a
plain shell invocation does not. On a PostgreSQL install the CLI silently
fell back to SQLite, so changes made from the management menu never
reached the panel's database. Load the systemd EnvironmentFile
(/etc/default/x-ui and distro equivalents) at startup; godotenv.Load does
not override existing vars, so it stays a no-op for the managed service.
2. DB backup/restore (panel endpoints and the Telegram bot) only handled the
SQLite file, so on PostgreSQL Back Up returned a stale/absent x-ui.db and
Restore silently did nothing. Add pg_dump/pg_restore based backup/restore:
- GetDb/ImportDB run pg_dump (custom format) / pg_restore, passing
credentials via the PG* environment instead of argv.
- getDb downloads x-ui.dump on Postgres, x-ui.db on SQLite.
- Telegram backup sends the matching file via GetDb.
- BackupModal shows a Postgres note and accepts .dump; the dist page
injects window.X_UI_DB_TYPE; new strings translated for all locales.
- install.sh installs postgresql-client for the external-DSN path and
points the user to in-panel Backup & Restore.
Closes #4658
370 lines
11 KiB
Go
370 lines
11 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{}
|
|
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("/getNewUUID", a.getNewUUID)
|
|
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)
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|