mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 14:21:01 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user