agent_nodes gains probe_only; getConfig always serves the flag and withholds the sing-box payload (null config, empty hash) for flagged nodes while probe tasks, fast interval, stat iface and update intents ride as usual. The agent gates both core branches when the local agent.json preset or the master flag is on, retires a running core once (best-effort tail flush, switchover bookkeeping untouched so turning the flag off self-heals through the existing hash-current-but-not-running path), and makes the traffic cycle an explicit no-op. Panel: node create/update accept probeOnly, the node view overlays it from the binding, the node form gains a switch (carried into the generated install command), the list shows a probe-only tag in the core-version cell, and install-agent.sh --probe-only writes the preset, skips the core download/self-check and inherits the flag on no-arg reruns.
399 lines
11 KiB
Go
399 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"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/middleware"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type NodeController struct {
|
|
nodeService service.NodeService
|
|
xrayService service.XrayService
|
|
}
|
|
|
|
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.GET("/webCert/:id", a.webCert)
|
|
|
|
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("/inbounds", a.inbounds)
|
|
g.POST("/probe/:id", a.probe)
|
|
g.POST("/updatePanel", a.updatePanel)
|
|
g.GET("/history/:id/:metric/:bucket", a.history)
|
|
g.POST("/mtls/ca", a.mtlsCa)
|
|
g.POST("/mtls/trustCA", a.setMtlsTrustCA)
|
|
g.POST("/mtls/reloadClient", a.reloadMtlsClient)
|
|
}
|
|
|
|
// reloadMtlsClient validates the credential currently stored by the master and
|
|
// closes cached mTLS pools so subsequent node requests present the new leaf.
|
|
func (a *NodeController) reloadMtlsClient(c *gin.Context) {
|
|
if err := a.nodeService.ReloadMasterMtlsClient(); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil)
|
|
}
|
|
|
|
// mtlsCa returns this panel's node-auth CA certificate (public) to paste into a
|
|
// node's mTLS trust setting. It lazily mints the CA + master client cert on
|
|
// first call.
|
|
func (a *NodeController) mtlsCa(c *gin.Context) {
|
|
caCert, err := a.nodeService.NodeMtlsCaCert()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, gin.H{"caCert": caCert}, nil)
|
|
}
|
|
|
|
// setMtlsTrustCA stores the CA this panel trusts for incoming node-API client
|
|
// certificates (this panel acting as a node). An empty value disables it.
|
|
// Applied on the next panel restart.
|
|
func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
|
|
var req struct {
|
|
CaCert string `json:"caCert" form:"caCert"`
|
|
}
|
|
if err := c.ShouldBind(&req); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
|
|
return
|
|
}
|
|
if err := a.nodeService.SetNodeMtlsTrustCA(req.CaCert); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
|
|
return
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), nil)
|
|
}
|
|
|
|
func (a *NodeController) list(c *gin.Context) {
|
|
nodes, err := a.nodeService.GetNodeTreeView()
|
|
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.GetViewById(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, n, nil)
|
|
}
|
|
|
|
// webCert returns the node's own web TLS certificate/key file paths so the
|
|
// inbound form's "Set Cert from Panel" can fill paths that exist on the node.
|
|
func (a *NodeController) webCert(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
|
return
|
|
}
|
|
files, err := a.nodeService.GetWebCertFiles(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
jsonObj(c, files, nil)
|
|
}
|
|
|
|
func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error {
|
|
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil {
|
|
return errors.New(service.FriendlyProbeError(err.Error()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *NodeController) add(c *gin.Context) {
|
|
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
isAgent := n.NodeType == "agent"
|
|
// Agent nodes have no panel to probe; they authenticate via agent token.
|
|
if n.OutboundTag == "" && !isAgent {
|
|
if err := a.ensureReachable(c, n, 0); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
return
|
|
}
|
|
}
|
|
view, err := a.nodeService.CreateFromRequest(n)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
return
|
|
}
|
|
if n.OutboundTag != "" {
|
|
if err := a.ensureReachable(c, n, view.Id); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
return
|
|
}
|
|
}
|
|
// For agent nodes, issue an agent token bound to the new node and
|
|
// return it so the UI can display it (shown only once).
|
|
if isAgent {
|
|
view.NodeType = "agent"
|
|
nodeID := view.Id
|
|
token, agentID, err := issueAgentToken(&nodeID, n.Name)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
|
return
|
|
}
|
|
// Probe-only (V2.1.0): preset the flag on the freshly issued
|
|
// agent row when the create form asked for it, so the very
|
|
// first config poll already serves no core payload. A failed
|
|
// preset only logs — the node and token are usable, and the
|
|
// flag can still be flipped from the edit form.
|
|
if n.ProbeOnly != nil && *n.ProbeOnly {
|
|
if err := database.GetDB().Model(&model.AgentNode{}).
|
|
Where("id = ?", agentID).
|
|
Update("probe_only", true).Error; err != nil {
|
|
logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err)
|
|
} else {
|
|
view.ProbeOnly = true
|
|
}
|
|
}
|
|
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
|
"node": view,
|
|
"agentToken": token,
|
|
"nodeType": "agent",
|
|
}, nil)
|
|
return
|
|
}
|
|
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, 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[service.NodeMutationRequest](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
old, err := a.nodeService.GetById(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
// Agent nodes have no panel to probe and no panel API token; editing one
|
|
// must skip the reachability check exactly like creation does.
|
|
isAgent := n.NodeType == "agent" || a.nodeService.IsAgentManaged(id)
|
|
if !isAgent && n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
|
|
if err := a.ensureReachable(c, n, id); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
}
|
|
if err := a.nodeService.UpdateFromRequest(id, n); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
if n.OutboundTag != old.OutboundTag {
|
|
if err := a.ensureReachable(c, n, id); 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
|
|
}
|
|
n, err := a.nodeService.GetById(id)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
|
return
|
|
}
|
|
if n.OutboundTag != "" {
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
|
}
|
|
|
|
func (a *NodeController) inbounds(c *gin.Context) {
|
|
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
defer cancel()
|
|
options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode)
|
|
jsonObj(c, options, err)
|
|
}
|
|
|
|
func (a *NodeController) test(c *gin.Context) {
|
|
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
var patch service.HeartbeatPatch
|
|
if runtimeNode.OutboundTag != "" {
|
|
patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag)
|
|
} else {
|
|
patch, err = a.nodeService.Probe(ctx, runtimeNode)
|
|
}
|
|
jsonObj(c, patch.ToUI(err == nil), nil)
|
|
}
|
|
|
|
func (a *NodeController) certFingerprint(c *gin.Context) {
|
|
n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c)
|
|
if !ok {
|
|
return
|
|
}
|
|
runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
|
defer cancel()
|
|
fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode)
|
|
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"
|
|
}
|
|
if err := a.nodeService.UpdateHeartbeat(id, patch); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
|
return
|
|
}
|
|
jsonObj(c, patch.ToUI(probeErr == nil), nil)
|
|
}
|
|
|
|
func (a *NodeController) updatePanel(c *gin.Context) {
|
|
var req struct {
|
|
Ids []int `json:"ids"`
|
|
Dev bool `json:"dev"`
|
|
}
|
|
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, req.Dev)
|
|
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)
|
|
}
|