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
+24 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
}