Przeglądaj źródła

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 <[email protected]>
Nikan Zeyaei 13 godzin temu
rodzic
commit
05ad7f417c

+ 5 - 0
frontend/public/openapi.json

@@ -1621,6 +1621,9 @@
             "example": 3,
             "type": "integer"
           },
+          "outboundTag": {
+            "type": "string"
+          },
           "panelVersion": {
             "example": "v3.x.x",
             "type": "string"
@@ -1708,6 +1711,7 @@
           "memPct",
           "name",
           "onlineCount",
+          "outboundTag",
           "panelVersion",
           "pinnedCertSha256",
           "port",
@@ -6118,6 +6122,7 @@
                       "memPct": 45.1,
                       "name": "de-fra-1",
                       "onlineCount": 3,
+                      "outboundTag": "",
                       "panelVersion": "v3.x.x",
                       "parentGuid": "",
                       "pinnedCertSha256": "",

+ 6 - 3
frontend/src/api/queries/useOutboundTags.ts

@@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting';
 // inbound's Telegram traffic to. Shares the cached xray config query so opening
 // the inbound form costs no extra request when the Xray page was already
 // visited; `select` derives just the tag list without disturbing other readers.
-export function useOutboundTags() {
+export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
+  const excludeBlackhole = opts?.excludeBlackhole ?? false;
   return useQuery({
     queryKey: keys.xray.config(),
     queryFn: fetchXrayConfig,
@@ -15,8 +16,10 @@ export function useOutboundTags() {
     select: (data): string[] => {
       const tags = new Set<string>();
       for (const o of data?.xraySetting?.outbounds ?? []) {
-        const tag = (o as { tag?: string } | null)?.tag;
-        if (tag) tags.add(tag);
+        const ob = o as { tag?: string; protocol?: string } | null;
+        if (!ob?.tag) continue;
+        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        tags.add(ob.tag);
       }
       for (const t of data?.subscriptionOutboundTags ?? []) {
         if (t) tags.add(t);

+ 1 - 0
frontend/src/generated/examples.ts

@@ -354,6 +354,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "memPct": 45.1,
     "name": "de-fra-1",
     "onlineCount": 3,
+    "outboundTag": "",
     "panelVersion": "v3.x.x",
     "parentGuid": "",
     "pinnedCertSha256": "",

+ 4 - 0
frontend/src/generated/schemas.ts

@@ -1595,6 +1595,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 3,
         "type": "integer"
       },
+      "outboundTag": {
+        "type": "string"
+      },
       "panelVersion": {
         "example": "v3.x.x",
         "type": "string"
@@ -1682,6 +1685,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "memPct",
       "name",
       "onlineCount",
+      "outboundTag",
       "panelVersion",
       "pinnedCertSha256",
       "port",

+ 1 - 0
frontend/src/generated/types.ts

@@ -358,6 +358,7 @@ export interface Node {
   memPct: number;
   name: string;
   onlineCount: number;
+  outboundTag: string;
   panelVersion: string;
   parentGuid?: string;
   pinnedCertSha256: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -384,6 +384,7 @@ export const NodeSchema = z.object({
   memPct: z.number(),
   name: z.string(),
   onlineCount: z.number().int(),
+  outboundTag: z.string(),
   panelVersion: z.string(),
   parentGuid: z.string().optional(),
   pinnedCertSha256: z.string(),

+ 17 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -18,6 +18,7 @@ import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
 import type { Msg } from '@/utils';
 import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
 import { antdRule } from '@/utils/zodForm';
+import { useOutboundTags } from '@/api/queries/useOutboundTags';
 import './NodeFormModal.css';
 
 type Mode = 'add' | 'edit';
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
     pinnedCertSha256: '',
     inboundSyncMode: 'all',
     inboundTags: [],
+    outboundTag: '',
   };
 }
 
@@ -75,6 +77,7 @@ export default function NodeFormModal({
   const scheme = Form.useWatch('scheme', form) ?? 'https';
   const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
   const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
+  const { data: outboundTags } = useOutboundTags({ excludeBlackhole: true });
 
   useEffect(() => {
     if (!open) return;
@@ -117,6 +120,7 @@ export default function NodeFormModal({
       pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
       inboundSyncMode: values.inboundSyncMode,
       inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
+      outboundTag: values.outboundTag || '',
     };
   }
 
@@ -356,6 +360,19 @@ export default function NodeFormModal({
             <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
           </Form.Item>
 
+          <Form.Item
+            label={t('pages.nodes.outboundTag')}
+            name="outboundTag"
+            extra={t('pages.nodes.outboundTagHint')}
+          >
+            <Select
+              allowClear
+              showSearch
+              placeholder={t('pages.nodes.outboundTagPlaceholder')}
+              options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
+            />
+          </Form.Item>
+
           <Form.Item
             label={t('pages.nodes.inboundSyncMode')}
             name="inboundSyncMode"

+ 2 - 0
frontend/src/schemas/node.ts

@@ -34,6 +34,7 @@ export const NodeRecordSchema = z.object({
   inboundSyncMode: z.enum(['all', 'selected']).optional(),
   // Backend serializes a nil []string as null for nodes saved before #5178.
   inboundTags: z.array(z.string()).nullish(),
+  outboundTag: z.string().optional(),
   // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
   // whether it's a read-only transitive sub-node surfaced from a downstream node.
   guid: z.string().optional(),
@@ -70,6 +71,7 @@ export const NodeFormSchema = z.object({
   // Unmounted when sync mode is "all" (absent from antd onFinish values) and
   // serialized as null by the backend for a nil slice — tolerate both.
   inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
+  outboundTag: z.string().optional(),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 1 - 0
internal/database/model/model.go

@@ -462,6 +462,7 @@ type Node struct {
 	PinnedCertSha256    string   `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 	InboundSyncMode     string   `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
 	InboundTags         []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
+	OutboundTag         string   `json:"outboundTag" form:"outboundTag" gorm:"column:outbound_tag"`
 
 	// Guid is the remote panel's stable self-identifier (its panelGuid),
 	// learned from each heartbeat. It is the globally stable node identity used

+ 51 - 6
internal/web/controller/node.go

@@ -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)
 }
 

+ 24 - 4
internal/web/runtime/manager.go

@@ -8,11 +8,16 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
+type NodeEgressResolver interface {
+	NodeEgressProxyURL(nodeID int) string
+}
+
 type Manager struct {
 	local Runtime
 
-	mu      sync.RWMutex
-	remotes map[int]*Remote
+	mu             sync.RWMutex
+	remotes        map[int]*Remote
+	egressResolver NodeEgressResolver
 }
 
 func NewManager(localDeps LocalDeps) *Manager {
@@ -22,6 +27,21 @@ func NewManager(localDeps LocalDeps) *Manager {
 	}
 }
 
+func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.egressResolver = r
+}
+
+func (m *Manager) NodeEgressProxyURL(nodeID int) string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	if m.egressResolver == nil {
+		return ""
+	}
+	return m.egressResolver.NodeEgressProxyURL(nodeID)
+}
+
 func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
 	if nodeID == nil {
 		return m.local, nil
@@ -45,7 +65,7 @@ func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
 	if !n.Enable {
 		return nil, errors.New("node " + n.Name + " is disabled")
 	}
-	rt := NewRemote(n)
+	rt := NewRemote(n, m.egressResolver)
 	m.remotes[*nodeID] = rt
 	return rt, nil
 }
@@ -68,7 +88,7 @@ func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
 	if rt, ok := m.remotes[node.Id]; ok {
 		return rt, nil
 	}
-	rt := NewRemote(node)
+	rt := NewRemote(node, m.egressResolver)
 	m.remotes[node.Id] = rt
 	return rt, nil
 }

+ 11 - 4
internal/web/runtime/remote.go

@@ -40,6 +40,8 @@ type Remote struct {
 	clientOnce sync.Once
 	client     *http.Client
 	clientErr  error
+
+	egressResolver NodeEgressResolver
 }
 
 type RemoteInboundOption struct {
@@ -49,10 +51,11 @@ type RemoteInboundOption struct {
 	Port     int            `json:"port"`
 }
 
-func NewRemote(n *model.Node) *Remote {
+func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
 	return &Remote{
-		node:          n,
-		remoteIDByTag: make(map[string]int),
+		node:           n,
+		remoteIDByTag:  make(map[string]int),
+		egressResolver: r,
 	}
 }
 
@@ -62,7 +65,11 @@ func (r *Remote) Name() string { return "node:" + r.node.Name }
 // verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264).
 func (r *Remote) httpClient() (*http.Client, error) {
 	r.clientOnce.Do(func() {
-		r.client, r.clientErr = HTTPClientForNode(r.node)
+		proxyURL := ""
+		if r.node.OutboundTag != "" && r.egressResolver != nil {
+			proxyURL = r.egressResolver.NodeEgressProxyURL(r.node.Id)
+		}
+		r.client, r.clientErr = HTTPClientForNode(r.node, proxyURL)
 	})
 	return r.client, r.clientErr
 }

+ 1 - 1
internal/web/runtime/remote_test.go

@@ -26,7 +26,7 @@ func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			r := NewRemote(&model.Node{Id: 1, Name: "n1"})
+			r := NewRemote(&model.Node{Id: 1, Name: "n1"}, nil)
 			r.cacheSet(c.cacheTag, 7)
 			id, ok := r.cacheGetTag(c.lookup)
 			if ok != c.wantFound || id != c.wantID {

+ 39 - 14
internal/web/runtime/tls_client.go

@@ -12,6 +12,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
 
@@ -26,19 +27,51 @@ var defaultNodeHTTPClient = &http.Client{
 	},
 }
 
-// HTTPClientForNode returns the node's HTTP client honoring its TLS verify mode
-// (verify→system CA, skip→no check, pin→leaf SHA-256). Used by both the probe
-// and every Remote op so they can't disagree on a self-signed node (#5264).
-func HTTPClientForNode(n *model.Node) (*http.Client, error) {
+func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	mode := n.TlsVerifyMode
 	if mode == "" {
 		mode = "verify"
 	}
+	if proxyURL != "" {
+		client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
+		if err != nil {
+			return nil, err
+		}
+		if mode == "verify" || n.Scheme == "http" {
+			return client, nil
+		}
+		transport, ok := client.Transport.(*http.Transport)
+		if !ok {
+			return client, nil
+		}
+		tlsCfg, err := tlsConfigForNode(n)
+		if err != nil {
+			return nil, err
+		}
+		transport.TLSClientConfig = tlsCfg
+		return client, nil
+	}
 	if mode == "verify" || n.Scheme == "http" {
 		return defaultNodeHTTPClient, nil
 	}
+	tlsCfg, err := tlsConfigForNode(n)
+	if err != nil {
+		return nil, err
+	}
+	return &http.Client{
+		Transport: &http.Transport{
+			MaxIdleConns:        64,
+			MaxIdleConnsPerHost: 4,
+			IdleConnTimeout:     60 * time.Second,
+			DialContext:         netsafe.SSRFGuardedDialContext,
+			TLSClientConfig:     tlsCfg,
+		},
+	}, nil
+}
+
+func tlsConfigForNode(n *model.Node) (*tls.Config, error) {
 	tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check]
-	if mode == "pin" {
+	if n.TlsVerifyMode == "pin" {
 		want, err := DecodeCertPin(n.PinnedCertSha256)
 		if err != nil {
 			return nil, err
@@ -54,15 +87,7 @@ func HTTPClientForNode(n *model.Node) (*http.Client, error) {
 			return nil
 		}
 	}
-	return &http.Client{
-		Transport: &http.Transport{
-			MaxIdleConns:        64,
-			MaxIdleConnsPerHost: 4,
-			IdleConnTimeout:     60 * time.Second,
-			DialContext:         netsafe.SSRFGuardedDialContext,
-			TLSClientConfig:     tlsCfg,
-		},
-	}, nil
+	return tlsCfg, nil
 }
 
 // DecodeCertPin decodes a SHA-256 cert pin given as base64 (Xray's

+ 4 - 4
internal/web/runtime/tls_client_test.go

@@ -72,7 +72,7 @@ func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			r := NewRemote(nodeForServer(t, srv, c.mode, c.pin))
+			r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil)
 			_, err := r.ListInboundOptions(context.Background())
 			if c.wantErr && err == nil {
 				t.Fatalf("mode %q: expected error, got nil", c.mode)
@@ -87,7 +87,7 @@ func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
 // The lazily-built client is cached for the Remote's lifetime so repeated
 // operations reuse one pooled transport rather than rebuilding TLS each call.
 func TestRemoteClientCached(t *testing.T) {
-	r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"})
+	r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil)
 	c1, err1 := r.httpClient()
 	c2, err2 := r.httpClient()
 	if err1 != nil || err2 != nil {
@@ -105,7 +105,7 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
 		{Scheme: "https", TlsVerifyMode: ""},
 		{Scheme: "http", TlsVerifyMode: "skip"},
 	} {
-		c, err := HTTPClientForNode(n)
+		c, err := HTTPClientForNode(n, "")
 		if err != nil {
 			t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
 		}
@@ -116,7 +116,7 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
 }
 
 func TestHTTPClientForNodePinInvalid(t *testing.T) {
-	if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}); err == nil {
+	if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}, ""); err == nil {
 		t.Fatal("expected error for invalid pin")
 	}
 }

+ 2 - 2
internal/web/service/inbound_node_reconcile_test.go

@@ -109,7 +109,7 @@ func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T)
 	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
 
 	svc := InboundService{}
-	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
 		t.Fatalf("ReconcileNode: %v", err)
 	}
 
@@ -133,7 +133,7 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
 	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
 
 	svc := InboundService{}
-	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
 		t.Fatalf("ReconcileNode: %v", err)
 	}
 

+ 122 - 3
internal/web/service/node.go

@@ -17,9 +17,12 @@ import (
 
 	"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/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 type HeartbeatPatch struct {
@@ -339,6 +342,7 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"pinned_cert_sha256":    in.PinnedCertSha256,
 		"inbound_sync_mode":     in.InboundSyncMode,
 		"inbound_tags":          string(inboundTagsJSON),
+		"outbound_tag":          in.OutboundTag,
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -353,7 +357,7 @@ func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node
 	if err := s.normalize(n); err != nil {
 		return nil, err
 	}
-	return runtime.NewRemote(n).ListInboundOptions(ctx)
+	return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
 }
 
 // EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
@@ -427,7 +431,13 @@ func (s *NodeService) Delete(id int) error {
 
 func (s *NodeService) SetEnable(id int, enable bool) error {
 	db := database.GetDB()
-	return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
+	if err := db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error; err != nil {
+		return err
+	}
+	if mgr := runtime.GetManager(); mgr != nil {
+		mgr.InvalidateNode(id)
+	}
+	return nil
 }
 
 // GetWebCertFiles asks a node for its own web TLS certificate/key file paths,
@@ -588,6 +598,115 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
 }
 
 func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
+	proxyURL := ""
+	if n.OutboundTag != "" {
+		if mgr := runtime.GetManager(); mgr != nil {
+			proxyURL = mgr.NodeEgressProxyURL(n.Id)
+		}
+	}
+	return s.probe(ctx, n, proxyURL)
+}
+
+func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outboundTag string) (HeartbeatPatch, error) {
+	if outboundTag == "" {
+		return s.Probe(ctx, n)
+	}
+	proc := XrayProcess()
+	if proc == nil || !proc.IsRunning() {
+		return s.Probe(ctx, n)
+	}
+	apiPort := proc.GetAPIPort()
+	if apiPort <= 0 {
+		return s.Probe(ctx, n)
+	}
+
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		return s.Probe(ctx, n)
+	}
+	port := listener.Addr().(*net.TCPAddr).Port
+	listener.Close()
+
+	tag := fmt.Sprintf("node-test-%d-%d", n.Id, time.Now().UnixNano())
+	proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
+
+	inboundJSON, err := json.Marshal(xray.InboundConfig{
+		Listen:   json_util.RawMessage(`"127.0.0.1"`),
+		Port:     port,
+		Protocol: "socks",
+		Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+		Tag:      tag,
+	})
+	if err != nil {
+		return s.Probe(ctx, n)
+	}
+
+	cfg := proc.GetConfig()
+	routing := map[string]any{}
+	if len(cfg.RouterConfig) > 0 {
+		_ = json.Unmarshal(cfg.RouterConfig, &routing)
+	}
+	rules, _ := routing["rules"].([]any)
+	rule := map[string]any{
+		"type":       "field",
+		"inboundTag": []any{tag},
+	}
+	if routingTagIsBalancer(routing, outboundTag) {
+		rule["balancerTag"] = outboundTag
+	} else {
+		rule["outboundTag"] = outboundTag
+	}
+	routing["rules"] = append([]any{rule}, rules...)
+	routingJSON, err := json.Marshal(routing)
+	if err != nil {
+		return s.Probe(ctx, n)
+	}
+	originalRoutingJSON := cfg.RouterConfig
+
+	api := xray.XrayAPI{}
+	if err := api.Init(apiPort); err != nil {
+		return s.Probe(ctx, n)
+	}
+	defer api.Close()
+
+	if err := api.AddInbound(inboundJSON); err != nil {
+		return s.Probe(ctx, n)
+	}
+	removed := false
+	defer func() {
+		if removed {
+			return
+		}
+		if err := api.DelInbound(tag); err != nil {
+			logger.Warning("remove temp node test inbound failed:", err)
+		}
+	}()
+
+	if err := api.ApplyRoutingConfig(routingJSON); err != nil {
+		return s.Probe(ctx, n)
+	}
+	defer func() {
+		restore := originalRoutingJSON
+		if len(restore) == 0 {
+			restore = []byte("{}")
+		}
+		if err := api.ApplyRoutingConfig(restore); err != nil {
+			logger.Warning("restore routing after node test failed:", err)
+		}
+	}()
+
+	patch, err := s.probe(ctx, n, proxyURL)
+	removed = true
+	if delErr := api.DelInbound(tag); delErr != nil {
+		logger.Warning("remove temp node test inbound failed:", delErr)
+	}
+	if err != nil {
+		return patch, err
+	}
+	return patch, nil
+}
+
+func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
 	patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
 
 	addr, err := netsafe.NormalizeHost(n.Address)
@@ -621,7 +740,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	req.Header.Set("Accept", "application/json")
 
-	client, err := runtime.HTTPClientForNode(n)
+	client, err := runtime.HTTPClientForNode(n, proxyURL)
 	if err != nil {
 		patch.LastError = err.Error()
 		return patch, err

+ 20 - 0
internal/web/service/setting.go

@@ -434,6 +434,26 @@ func (s *SettingService) PanelEgressProxyURL() string {
 	return ""
 }
 
+func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
+	tag := NodeEgressInboundTag(nodeID)
+	proc := XrayProcess()
+	if proc == nil || !proc.IsRunning() {
+		logger.Warning("node outbound [", tag, "] is set but Xray is not running, using a direct connection")
+		return ""
+	}
+	cfg := proc.GetConfig()
+	if cfg == nil {
+		return ""
+	}
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == tag {
+			return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
+		}
+	}
+	logger.Warning("node outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
+	return ""
+}
+
 // NewProxiedHTTPClient returns an HTTP client that routes the panel's own
 // outbound requests through the configured panel outbound (via the loopback
 // SOCKS bridge in the running Xray). When the feature is off or the bridge

+ 91 - 0
internal/web/service/xray.go

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
 	"path"
 	"path/filepath"
 	"runtime"
@@ -31,6 +32,7 @@ var (
 type XrayService struct {
 	inboundService InboundService
 	settingService SettingService
+	nodeService    NodeService
 	xrayAPI        xray.XrayAPI
 }
 
@@ -296,6 +298,13 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		injectPanelEgress(xrayConfig, egressTag)
 	}
 
+	nodes, err := s.nodeService.GetAll()
+	if err != nil {
+		logger.Warning("read nodes for egress injection failed:", err)
+	} else {
+		injectNodeEgresses(xrayConfig, nodes)
+	}
+
 	return xrayConfig, nil
 }
 
@@ -372,6 +381,88 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	})
 }
 
+// NodeEgressInboundTag returns the loopback SOCKS inbound tag for a given node.
+func NodeEgressInboundTag(nodeID int) string {
+	return fmt.Sprintf("node-egress-%d", nodeID)
+}
+
+// nodeEgressBasePort is the first port tried for node egress bridges.
+const nodeEgressBasePort = 62800
+
+// injectNodeEgresses appends a loopback SOCKS inbound per enabled node that has
+// an OutboundTag, and prepends a routing rule sending that inbound's traffic to
+// the selected outbound tag. These bridges are hot-appliable.
+func injectNodeEgresses(cfg *xray.Config, nodes []*model.Node) {
+	routing := map[string]any{}
+	if len(cfg.RouterConfig) > 0 {
+		if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+			logger.Warning("node egress: routing section is unparsable, skipping injection:", err)
+			return
+		}
+	}
+
+	used := make(map[int]struct{}, len(cfg.InboundConfigs))
+	usedTags := make(map[string]struct{}, len(cfg.InboundConfigs))
+	for i := range cfg.InboundConfigs {
+		used[cfg.InboundConfigs[i].Port] = struct{}{}
+		usedTags[cfg.InboundConfigs[i].Tag] = struct{}{}
+	}
+
+	rules, _ := routing["rules"].([]any)
+	newRules := make([]any, 0)
+
+	for _, n := range nodes {
+		if !n.Enable || n.OutboundTag == "" {
+			continue
+		}
+		tag := NodeEgressInboundTag(n.Id)
+		if _, exists := usedTags[tag]; exists {
+			logger.Warning("node egress: inbound tag [", tag, "] already exists, skipping")
+			continue
+		}
+		usedTags[tag] = struct{}{}
+
+		rule := map[string]any{
+			"type":       "field",
+			"inboundTag": []any{tag},
+		}
+		if routingTagIsBalancer(routing, n.OutboundTag) {
+			rule["balancerTag"] = n.OutboundTag
+		} else {
+			rule["outboundTag"] = n.OutboundTag
+		}
+		newRules = append(newRules, rule)
+
+		port := nodeEgressBasePort + n.Id
+		for {
+			if _, taken := used[port]; !taken {
+				break
+			}
+			port++
+		}
+		used[port] = struct{}{}
+
+		cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+			Listen:   json_util.RawMessage(`"127.0.0.1"`),
+			Port:     port,
+			Protocol: "socks",
+			Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+			Tag:      tag,
+		})
+	}
+
+	if len(newRules) == 0 {
+		return
+	}
+	routing["rules"] = append(newRules, rules...)
+	newRouting, err := json.Marshal(routing)
+	if err != nil {
+		logger.Warning("node egress: failed to rebuild routing section, skipping injection:", err)
+		return
+	}
+	cfg.RouterConfig = json_util.RawMessage(newRouting)
+}
+
 // routingTagIsBalancer reports whether tag names a balancer in the parsed
 // routing section. The panel-egress rule targets a balancer via balancerTag and
 // a concrete outbound via outboundTag, so the caller picks the key from this.

+ 3 - 0
internal/web/translation/ar-EG.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "allowPrivateAddress": "السماح بالعنوان الخاص",
       "allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
+      "outboundTag": "اتصال صادر",
+      "outboundTagHint": "وجه حركة مرور API اللوحة لهذه العقدة عبر outbound Xray المحدد. يتم إضافة inbound جسر loopback تلقائيًا إلى التكوين قيد التشغيل وتطبيقه مباشرة. اتركه فارغًا للاتصال المباشر.",
+      "outboundTagPlaceholder": "اتصال مباشر",
       "inboundSyncMode": "استيراد الاتصالات الواردة",
       "inboundSyncModeHint": "اختر الاتصالات الواردة التي سيتم استيرادها من هذه العقدة. تستورد العقد الحالية جميع الاتصالات افتراضيًا.",
       "allInbounds": "جميع الاتصالات الواردة",

+ 3 - 0
internal/web/translation/en-US.json

@@ -887,6 +887,9 @@
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "allowPrivateAddress": "Allow private address",
       "allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
+      "outboundTag": "Connection outbound",
+      "outboundTagHint": "Route this node's panel API traffic through the selected Xray outbound. A loopback bridge inbound is added to the running config automatically and applied live. Leave empty for a direct connection.",
+      "outboundTagPlaceholder": "Direct connection",
       "inboundSyncMode": "Inbound import",
       "inboundSyncModeHint": "Choose which inbounds are imported from this node. Existing nodes default to all inbounds.",
       "allInbounds": "All inbounds",

+ 3 - 0
internal/web/translation/es-ES.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "allowPrivateAddress": "Permitir dirección privada",
       "allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
+      "outboundTag": "Outbound de conexión",
+      "outboundTagHint": "Enruta el tráfico de la API del panel de este nodo a través del outbound Xray seleccionado. Un inbound de puente loopback se agrega automáticamente a la configuración en ejecución y se aplica en vivo. Déjelo vacío para una conexión directa.",
+      "outboundTagPlaceholder": "Conexión directa",
       "inboundSyncMode": "Importación de inbounds",
       "inboundSyncModeHint": "Elige qué inbounds importar desde este nodo. Los nodos existentes importan todos de forma predeterminada.",
       "allInbounds": "Todos los inbounds",

+ 3 - 0
internal/web/translation/fa-IR.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "allowPrivateAddress": "اجازه آدرس خصوصی",
       "allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
+      "outboundTag": "خروجی اتصال",
+      "outboundTagHint": "ترافیک API پنل این نود را از طریق خروجی Xray انتخاب‌شده مسیریابی کنید. یک inbound پل loopback به‌صورت خودکار به پیکربندی در حال اجرا اضافه شده و به‌صورت زنده اعمال می‌شود. برای اتصال مستقیم خالی بگذارید.",
+      "outboundTagPlaceholder": "اتصال مستقیم",
       "inboundSyncMode": "وارد کردن اینباندها",
       "inboundSyncModeHint": "اینباندهای قابل وارد کردن از این نود را انتخاب کنید. نودهای موجود به‌طور پیش‌فرض همه را وارد می‌کنند.",
       "allInbounds": "همه اینباندها",

+ 3 - 0
internal/web/translation/id-ID.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "allowPrivateAddress": "Izinkan alamat pribadi",
       "allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
+      "outboundTag": "Outbound koneksi",
+      "outboundTagHint": "Rutekan lalu lintas API panel node ini melalui outbound Xray yang dipilih. Sebuah inbound jembatan loopback ditambahkan secara otomatis ke konfigurasi yang berjalan dan diterapkan secara langsung. Biarkan kosong untuk koneksi langsung.",
+      "outboundTagPlaceholder": "Koneksi langsung",
       "inboundSyncMode": "Impor inbound",
       "inboundSyncModeHint": "Pilih inbound yang diimpor dari node ini. Node yang sudah ada mengimpor semua inbound secara default.",
       "allInbounds": "Semua inbound",

+ 3 - 0
internal/web/translation/ja-JP.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "allowPrivateAddress": "プライベートアドレスを許可",
       "allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
+      "outboundTag": "接続アウトバウンド",
+      "outboundTagHint": "選択した Xray アウトバウンドを経由して、このノードのパネル API トラフィックをルーティングします。ループバック ブリッジ inbound は実行中の設定に自動的に追加され、リアルタイムで適用されます。空のままにすると直接接続になります。",
+      "outboundTagPlaceholder": "直接接続",
       "inboundSyncMode": "インバウンドのインポート",
       "inboundSyncModeHint": "このノードからインポートするインバウンドを選択します。既存のノードは既定ですべてをインポートします。",
       "allInbounds": "すべてのインバウンド",

+ 3 - 0
internal/web/translation/pt-BR.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "allowPrivateAddress": "Permitir endereço privado",
       "allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
+      "outboundTag": "Outbound de conexão",
+      "outboundTagHint": "Roteie o tráfego da API do painel deste nó pelo outbound Xray selecionado. Um inbound de ponte loopback é adicionado automaticamente à configuração em execução e aplicado ao vivo. Deixe em branco para uma conexão direta.",
+      "outboundTagPlaceholder": "Conexão direta",
       "inboundSyncMode": "Importação de inbounds",
       "inboundSyncModeHint": "Escolha quais inbounds importar deste nó. Nós existentes importam todos por padrão.",
       "allInbounds": "Todos os inbounds",

+ 3 - 0
internal/web/translation/ru-RU.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "allowPrivateAddress": "Разрешить частный адрес",
       "allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
+      "outboundTag": "Исходящее подключение",
+      "outboundTagHint": "Маршрутизируйте трафик API панели этого узла через выбранный исходящий Xray. Входящий мост обратной петли автоматически добавляется в текущую конфигурацию и применяется в реальном времени. Оставьте пустым для прямого подключения.",
+      "outboundTagPlaceholder": "Прямое подключение",
       "inboundSyncMode": "Импорт инбаундов",
       "inboundSyncModeHint": "Выберите, какие инбаунды импортировать с этой ноды. Для существующих нод по умолчанию импортируются все.",
       "allInbounds": "Все инбаунды",

+ 3 - 0
internal/web/translation/tr-TR.json

@@ -887,6 +887,9 @@
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "allowPrivateAddress": "Özel Adrese İzin Ver",
       "allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştirin.",
+      "outboundTag": "Bağlantı gideni",
+      "outboundTagHint": "Bu düğümün panel API trafiğini seçilen Xray gideni üzerinden yönlendirin. Geri döngü köprüsü inbound'ı çalışan yapılandırmaya otomatik olarak eklenir ve canlı uygulanır. Doğrudan bağlantı için boş bırakın.",
+      "outboundTagPlaceholder": "Doğrudan bağlantı",
       "inboundSyncMode": "Inbound içe aktarma",
       "inboundSyncModeHint": "Bu düğümden içe aktarılacak inbound'ları seçin. Mevcut düğümler varsayılan olarak tümünü içe aktarır.",
       "allInbounds": "Tüm inbound'lar",

+ 3 - 0
internal/web/translation/uk-UA.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "allowPrivateAddress": "Дозволити приватну адресу",
       "allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
+      "outboundTag": "Вихідне з'єднання",
+      "outboundTagHint": "Маршрутизуйте трафік API панелі цього вузла через вибраний вихідний Xray. Вхідний міст зворотної петлі автоматично додається до поточної конфігурації та застосовується в реальному часі. Залиште порожнім для прямого підключення.",
+      "outboundTagPlaceholder": "Пряме підключення",
       "inboundSyncMode": "Імпорт інбаундів",
       "inboundSyncModeHint": "Виберіть інбаунди для імпорту з цього вузла. Для наявних вузлів типово імпортуються всі.",
       "allInbounds": "Усі інбаунди",

+ 3 - 0
internal/web/translation/vi-VN.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "allowPrivateAddress": "Cho phép địa chỉ riêng",
       "allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
+      "outboundTag": "Outbound kết nối",
+      "outboundTagHint": "Định tuyến lưu lượng API panel của node này qua outbound Xray đã chọn. Một inbound cầu nối loopback được tự động thêm vào cấu hình đang chạy và áp dụng trực tiếp. Để trống để kết nối trực tiếp.",
+      "outboundTagPlaceholder": "Kết nối trực tiếp",
       "inboundSyncMode": "Nhập inbound",
       "inboundSyncModeHint": "Chọn các inbound được nhập từ nút này. Các nút hiện có mặc định nhập tất cả.",
       "allInbounds": "Tất cả inbound",

+ 3 - 0
internal/web/translation/zh-CN.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "allowPrivateAddress": "允许私有地址",
       "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
+      "outboundTag": "连接出站",
+      "outboundTagHint": "通过选定的 Xray 出站路由此节点的面板 API 流量。系统会自动将回环桥接入站添加到运行配置并实时应用。留空表示直接连接。",
+      "outboundTagPlaceholder": "直接连接",
       "inboundSyncMode": "入站导入",
       "inboundSyncModeHint": "选择要从此节点导入的入站。现有节点默认导入全部入站。",
       "allInbounds": "全部入站",

+ 3 - 0
internal/web/translation/zh-TW.json

@@ -886,6 +886,9 @@
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "allowPrivateAddress": "允許私有地址",
       "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
+      "outboundTag": "連線出站",
+      "outboundTagHint": "透過選定的 Xray 出站路由此節點的面板 API 流量。系統會自動將迴環橋接入站加入執行中的設定並即時套用。留空表示直接連線。",
+      "outboundTagPlaceholder": "直接連線",
       "inboundSyncMode": "入站匯入",
       "inboundSyncModeHint": "選擇要從此節點匯入的入站。現有節點預設匯入所有入站。",
       "allInbounds": "所有入站",

+ 1 - 0
internal/web/web.go

@@ -404,6 +404,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		APIPort:        func() int { return s.xrayService.GetXrayAPIPort() },
 		SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
 	}))
+	runtime.GetManager().SetNodeEgressResolver(&s.settingService)
 
 	engine, err := s.initRouter()
 	if err != nil {