mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-03 10:59:34 +00:00
The pinnedCertSha256 form field unmounts for non-pin TLS modes, so antd dropped it from the onFinish values and Zod rejected the missing string (the user-facing "invalid input"). Make it optional with a default so saving works in every TLS mode. Saving now runs the connection test first and only persists when the probe is online; the add/update endpoints enforce the same probe so an unreachable node cannot be stored via the API either. Selecting the http scheme forces TLS verify mode to skip and disables the control, normalized on open for existing http nodes. http-vs-https probe failures report a clear "set the node scheme to http" message across the test button, save, and the backend gate. Closes #4794
245 lines
6.0 KiB
Go
245 lines
6.0 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/web/middleware"
|
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type NodeController struct {
|
|
nodeService service.NodeService
|
|
}
|
|
|
|
func NewNodeController(g *gin.RouterGroup) *NodeController {
|
|
a := &NodeController{}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
|
g.GET("/list", a.list)
|
|
g.GET("/get/:id", a.get)
|
|
|
|
g.POST("/add", a.add)
|
|
g.POST("/update/:id", a.update)
|
|
g.POST("/del/:id", a.del)
|
|
g.POST("/setEnable/:id", a.setEnable)
|
|
|
|
g.POST("/test", a.test)
|
|
g.POST("/certFingerprint", a.certFingerprint)
|
|
g.POST("/probe/:id", a.probe)
|
|
g.POST("/updatePanel", a.updatePanel)
|
|
g.GET("/history/:id/:metric/:bucket", a.history)
|
|
}
|
|
|
|
func (a *NodeController) list(c *gin.Context) {
|
|
nodes, err := a.nodeService.GetAll()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
|
|
return
|
|
}
|
|
jsonObj(c, nodes, nil)
|
|
}
|
|
|
|
func (a *NodeController) get(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
n, err := a.nodeService.GetById(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, n, nil)
|
|
}
|
|
|
|
func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
if _, err := a.nodeService.Probe(ctx, n); err != nil {
|
|
return errors.New(service.FriendlyProbeError(err.Error()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *NodeController) add(c *gin.Context) {
|
|
n, ok := middleware.BindAndValidate[model.Node](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := a.ensureReachable(c, n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
return
|
|
}
|
|
if err := a.nodeService.Create(n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
return
|
|
}
|
|
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
|
|
}
|
|
|
|
func (a *NodeController) update(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
n, ok := middleware.BindAndValidate[model.Node](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := a.ensureReachable(c, n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
if err := a.nodeService.Update(id, n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
|
}
|
|
|
|
func (a *NodeController) del(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
if err := a.nodeService.Delete(id); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), nil)
|
|
}
|
|
|
|
func (a *NodeController) setEnable(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
body := struct {
|
|
Enable bool `json:"enable" form:"enable"`
|
|
}{}
|
|
if err := c.ShouldBind(&body); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
|
}
|
|
|
|
func (a *NodeController) test(c *gin.Context) {
|
|
n := &model.Node{}
|
|
if err := c.ShouldBind(n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
|
return
|
|
}
|
|
if n.Scheme == "" {
|
|
n.Scheme = "https"
|
|
}
|
|
if n.BasePath == "" {
|
|
n.BasePath = "/"
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
patch, err := a.nodeService.Probe(ctx, n)
|
|
jsonObj(c, patch.ToUI(err == nil), nil)
|
|
}
|
|
|
|
func (a *NodeController) certFingerprint(c *gin.Context) {
|
|
n := &model.Node{}
|
|
if err := c.ShouldBind(n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
|
return
|
|
}
|
|
if n.Scheme == "" {
|
|
n.Scheme = "https"
|
|
}
|
|
if n.BasePath == "" {
|
|
n.BasePath = "/"
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
|
return
|
|
}
|
|
jsonObj(c, fp, nil)
|
|
}
|
|
|
|
func (a *NodeController) probe(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
n, err := a.nodeService.GetById(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
patch, probeErr := a.nodeService.Probe(ctx, n)
|
|
if probeErr != nil {
|
|
patch.Status = "offline"
|
|
} else {
|
|
patch.Status = "online"
|
|
}
|
|
_ = a.nodeService.UpdateHeartbeat(id, patch)
|
|
jsonObj(c, patch.ToUI(probeErr == nil), nil)
|
|
}
|
|
|
|
func (a *NodeController) updatePanel(c *gin.Context) {
|
|
var req struct {
|
|
Ids []int `json:"ids"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
if len(req.Ids) == 0 {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
|
|
return
|
|
}
|
|
results, err := a.nodeService.UpdatePanels(req.Ids)
|
|
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
|
|
}
|
|
|
|
func (a *NodeController) history(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
metric := c.Param("metric")
|
|
if !slices.Contains(service.NodeMetricKeys, 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.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
|
|
}
|