feat(node): per node outbound routing (#5275)

* feat: add per-node outbound routing for panel-to-node connections

* feat(ui): add outbound tag selector to node form with i18n

* fix(xray): avoid potential overflow warning in node egress rule allocation

* chore: run "npm run gen"

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Nikan Zeyaei
2026-06-15 00:40:52 +03:30
committed by GitHub
parent 2188830612
commit 05ad7f417c
33 changed files with 443 additions and 41 deletions
+51 -6
View File
@@ -9,6 +9,7 @@ import (
"time"
"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"
@@ -17,6 +18,7 @@ import (
type NodeController struct {
nodeService service.NodeService
xrayService service.XrayService
}
func NewNodeController(g *gin.RouterGroup) *NodeController {
@@ -96,14 +98,25 @@ func (a *NodeController) add(c *gin.Context) {
if !ok {
return
}
if err := a.ensureReachable(c, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
if n.OutboundTag == "" {
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
}
if n.OutboundTag != "" {
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warning("apply node outbound bridge failed:", err)
}
if err := a.ensureReachable(c, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
}
@@ -117,14 +130,30 @@ func (a *NodeController) update(c *gin.Context) {
if !ok {
return
}
if err := a.ensureReachable(c, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
old, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
if n.OutboundTag == "" && old.OutboundTag == "" {
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
}
if n.OutboundTag != old.OutboundTag {
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warning("apply node outbound bridge change failed:", err)
}
if err := a.ensureReachable(c, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
@@ -154,10 +183,20 @@ func (a *NodeController) setEnable(c *gin.Context) {
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 != "" {
if err := a.xrayService.RestartXray(false); err != nil {
logger.Warning("apply node enable change failed:", err)
}
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
@@ -188,7 +227,13 @@ func (a *NodeController) test(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, err := a.nodeService.Probe(ctx, n)
var patch service.HeartbeatPatch
var err error
if n.OutboundTag != "" {
patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag)
} else {
patch, err = a.nodeService.Probe(ctx, n)
}
jsonObj(c, patch.ToUI(err == nil), nil)
}