Files
3x-ui-agent/internal/web/controller/server.go
T
Muse 277838d7ca ITEM6 B4: frontend reshaping — xray page retired, stats switches to settings, template read paths closed
- Delete the xray settings page (routes/menu/title), geodata UI/service,
  XraySettingController/Service and routing-sync write paths; the stored
  xrayTemplateConfig row loses its last readers (GetXrayConfigTemplate now
  returns the embedded factory template; B1 already deleted the row).
- Settings GeneralTab gains the four trafficStatsSwitches switches
  (settings.trafficStatsSwitches, B1) with the dual warning copy; entity
  AllSetting carries the key end to end (TestTrafficStatsSwitchesPlumbing).
- Tag-directory consumers (panelOutbound, mtproto egress, node/host
  outbound pickers) move from POST /xray/ to GET /server/outboundDirectory.
- Shared Mux/Sockopt forms and balancer helpers relocated to lib/xray;
  converter preserves sniffing ips/domains exclusions into the panel
  sidecar instead of dropping them (B2 gap closed by retention).
- Freedom final-rules DB fix seeders retired; getDefaultJsonConfig
  endpoint removed (no in-repo consumer); locales pruned to referenced
  keys (pages.xray 331 -> 68) plus the five new settings keys.
2026-10-22 23:40:54 +00:00

437 lines
15 KiB
Go

package controller
import (
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/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 panel.PanelService
}
// 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("/history/:metric/:bucket", a.getOverviewHistoryBucket)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getUpdateStatus", a.getUpdateStatus)
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.GET("/clientIps", a.getClientIps)
g.GET("/fail2banStatus", a.getFail2banStatus)
g.GET("/outboundDirectory", a.getOutboundDirectory)
g.POST("/updatePanel", a.updatePanel)
g.POST("/setUpdateChannel", a.setUpdateChannel)
g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/getCertHash", a.getCertHash)
g.POST("/getRemoteCertHash", a.getRemoteCertHash)
g.POST("/scanRealityTarget", a.scanRealityTarget)
g.POST("/scanRealityTargets", a.scanRealityTargets)
g.POST("/clientIps", a.setClientIps)
}
// startTask registers the @2s ticker that refreshes server status and pushes
// the new snapshot to all websocket subscribers. State lives in
// ServerService; the controller only orchestrates the broadcast. (The xray
// metrics sampling and the per-minute system-history persistence that used
// to ride along here were removed with those features.)
func (a *ServerController) startTask() {
c := global.GetWebServer().GetCron()
_, _ = c.AddFunc("@every 2s", func() {
status := a.serverService.RefreshStatus()
if status == nil {
return
}
websocket.BroadcastStatus(status)
})
}
// status returns the current server status information.
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
// getOverviewHistoryBucket returns up to 60 buckets of one homepage
// overview metric (see service.OverviewMetricKeys). It backs only the
// index-page sparkline seed; the store behind it is the memory-only
// overview ring — not the removed system-history API (no other metrics,
// no persistence, nothing to browse beyond the overview window).
func (a *ServerController) getOverviewHistoryBucket(c *gin.Context) {
metric := c.Param("metric")
if !slices.Contains(service.OverviewMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.serverService.AggregateOverviewMetric(metric, bucket, 60), nil)
}
func (a *ServerController) getFail2banStatus(c *gin.Context) {
jsonObj(c, a.serverService.GetFail2banStatus(), 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)
}
// updatePanel starts a panel self-update. With no "dev" form value it follows
// this panel's own channel setting; an explicit "dev" (sent by the master node
// updater) overrides it for this run. The response's runId identifies this
// update for a later getUpdateStatus poll.
func (a *ServerController) updatePanel(c *gin.Context) {
devParam := c.PostForm("dev")
var runID int64
var err error
if devParam == "" {
runID, err = a.panelService.StartUpdate()
} else {
dev, perr := strconv.ParseBool(devParam)
if perr != nil {
jsonMsg(c, "invalid data", perr)
return
}
runID, err = a.panelService.StartUpdateChannel(dev)
}
var obj any
if err == nil {
obj = gin.H{"runId": strconv.FormatInt(runID, 10)}
}
jsonMsgObj(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), obj, err)
}
// getUpdateStatus reports the outcome of the most recently launched panel
// self-update (see updatePanel). Compare the returned runId against the one
// updatePanel returned to tell this run's result apart from a stale one.
func (a *ServerController) getUpdateStatus(c *gin.Context) {
jsonObj(c, a.panelService.GetUpdateStatus(), nil)
}
// setUpdateChannel toggles whether self-update tracks the rolling dev release.
func (a *ServerController) setUpdateChannel(c *gin.Context) {
dev, err := strconv.ParseBool(c.PostForm("dev"))
if err != nil {
jsonMsg(c, "invalid data", err)
return
}
err = a.settingService.SetDevChannelEnable(dev)
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
}
// 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 := a.serverService.BackupFilename(c.Request.Host)
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()
// Absent field keeps this machine's own listen addresses, certificates and
// node identity: the safe default for the common case of moving a config to
// a new host. Send keepHostSettings=false to clone a machine wholesale.
keepHostSettings := c.Request.FormValue("keepHostSettings") != "false"
if err := a.serverService.ImportDB(file, keepHostSettings); 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)
}
// getCertHash returns the hex SHA-256 of the given certificate (file path or
// inline content) so the panel can fill the pinned-cert field.
func (a *ServerController) getCertHash(c *gin.Context) {
hashes, err := a.serverService.GetCertHash(c.PostForm("certFile"), c.PostForm("certContent"))
if err != nil {
jsonMsg(c, "get cert hash", err)
return
}
jsonObj(c, hashes, nil)
}
// getRemoteCertHash runs `xray tls ping` against the given server and returns
// its live certificate SHA-256 hash(es) for pinning.
func (a *ServerController) getRemoteCertHash(c *gin.Context) {
hashes, err := a.serverService.GetRemoteCertHash(c.PostForm("server"))
if err != nil {
jsonMsg(c, "get remote cert hash", err)
return
}
jsonObj(c, hashes, nil)
}
// scanRealityTarget probes the candidate REALITY target with the given sni and
// returns a feasibility verdict; allowPrivate is the panel's confirmed opt-in.
func (a *ServerController) scanRealityTarget(c *gin.Context) {
xver, _ := strconv.Atoi(c.PostForm("xver"))
allowPrivate := c.PostForm("allowPrivate") == "true"
res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), c.PostForm("sni"), xver, allowPrivate)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
return
}
jsonObj(c, res, nil)
}
// scanRealityTargets probes a batch of candidate REALITY targets (the supplied
// comma-separated list, or the built-in seed set when empty) and returns each
// verdict ranked by feasibility then latency.
func (a *ServerController) scanRealityTargets(c *gin.Context) {
res, err := a.serverService.ScanRealityTargets(c.PostForm("targets"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
return
}
jsonObj(c, res, 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.
// getOutboundDirectory returns the outbound tag directory the tag
// pickers in surviving pages (panel egress, node outbound, mtproto
// egress, host dialerProxy) choose from: the factory template's
// outbounds and routing balancers plus the tags contributed by
// outbound subscriptions. It replaces the directory half of the
// retired POST /panel/api/xray/ payload (ITEM6 B4): since the xray
// template became code-fixed, the directory is derived from the
// factory config instead of a user-editable stored template.
func (a *ServerController) getOutboundDirectory(c *gin.Context) {
type outboundRef struct {
Tag string `json:"tag"`
Protocol string `json:"protocol"`
}
outbounds := []outboundRef{}
balancers := []string{}
if cfg, err := a.settingService.GetDefaultJSONConfig(); err == nil {
if root, ok := cfg.(map[string]any); ok {
if raw, ok := root["outbounds"].([]any); ok {
for _, item := range raw {
ob, ok := item.(map[string]any)
if !ok {
continue
}
tag, _ := ob["tag"].(string)
if tag == "" {
continue
}
protocol, _ := ob["protocol"].(string)
outbounds = append(outbounds, outboundRef{Tag: tag, Protocol: protocol})
}
}
if routing, ok := root["routing"].(map[string]any); ok {
if raw, ok := routing["balancers"].([]any); ok {
for _, item := range raw {
b, ok := item.(map[string]any)
if !ok {
continue
}
if tag, _ := b["tag"].(string); tag != "" {
balancers = append(balancers, tag)
}
}
}
}
}
}
subTags, err := (&service.OutboundSubscriptionService{}).AllActiveOutboundTags()
if err != nil {
subTags = []string{}
}
jsonObj(c, map[string]any{
"outbounds": outbounds,
"balancers": balancers,
"subscriptionOutboundTags": subTags,
}, nil)
}
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)
}
func (a *ServerController) getClientIps(c *gin.Context) {
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
jsonObj(c, ips, err)
}
func (a *ServerController) setClientIps(c *gin.Context) {
var ips []model.InboundClientIps
if err := c.ShouldBindJSON(&ips); err != nil {
jsonMsg(c, "invalid data", err)
return
}
err := (&service.InboundService{}).MergeInboundClientIps(ips)
jsonMsg(c, "Client IPs merged", err)
}