Files
Muse 1035d00fe4 精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)
- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
2026-10-21 18:43:55 +00:00

382 lines
11 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"time"
"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, _, err := issueAgentToken(&nodeID, n.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
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)
}