feat: Global nodes registry for reusing nodes across hops

This commit is contained in:
iBug
2026-10-02 22:23:34 +08:00
parent 2306f45bf9
commit c67b2ec1d2
12 changed files with 295 additions and 9 deletions
+1
View File
@@ -774,6 +774,7 @@ type Config struct {
Services []*ServiceConfig `json:"services"`
Chains []*ChainConfig `yaml:",omitempty" json:"chains,omitempty"`
Hops []*HopConfig `yaml:",omitempty" json:"hops,omitempty"`
Nodes []*NodeConfig `yaml:",omitempty" json:"nodes,omitempty"`
Authers []*AutherConfig `yaml:",omitempty" json:"authers,omitempty"`
Admissions []*AdmissionConfig `yaml:",omitempty" json:"admissions,omitempty"`
Bypasses []*BypassConfig `yaml:",omitempty" json:"bypasses,omitempty"`
+24 -2
View File
@@ -6,6 +6,9 @@
package loader
import (
"fmt"
"strings"
"github.com/go-gost/core/admission"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
@@ -22,8 +25,8 @@ import (
"github.com/go-gost/core/recorder"
reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver"
"github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/router"
"github.com/go-gost/core/sd"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
@@ -320,7 +323,26 @@ func register(cfg *config.Config) error {
}
}
// --- hops (references bypasses, resolvers, hosts from registries) ---
// --- nodes (definitions referenced by hops) ---
{
var entries []named[*config.NodeConfig]
for _, c := range cfg.Nodes {
if c == nil {
continue
}
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" {
return fmt.Errorf("global node name is required")
}
entries = append(entries, named[*config.NodeConfig]{c.Name, c})
}
if err := registerGroup(entries, registry.NodeRegistry()); err != nil {
return err
}
}
// --- hops (references nodes, bypasses, resolvers, hosts from registries) ---
{
var entries []named[hop.Hop]
+75
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/listener"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
@@ -17,6 +18,9 @@ import (
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
"github.com/go-gost/x/registry"
_ "github.com/go-gost/x/connector/http"
_ "github.com/go-gost/x/dialer/tcp"
)
func TestMain(m *testing.M) {
@@ -465,6 +469,77 @@ func TestRegister_Hops(t *testing.T) {
}
}
func TestRegister_GlobalNodesBeforeHops(t *testing.T) {
nodeName := "test-global-node"
hopName := "test-global-node-hop"
t.Cleanup(func() {
registry.HopRegistry().Unregister(hopName)
registry.NodeRegistry().Unregister(nodeName)
})
cfg := &config.Config{
Nodes: []*config.NodeConfig{{
Name: nodeName,
Addr: "example.com:8080",
}},
Hops: []*config.HopConfig{{
Name: hopName,
Nodes: []*config.NodeConfig{{Name: nodeName}},
}},
}
if err := register(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
definition := registry.NodeRegistry().Get(nodeName)
if definition == nil {
t.Fatal("expected global node to be registered")
}
nodes := registry.HopRegistry().Get(hopName).(hop.NodeList).Nodes()
if len(nodes) != 1 || nodes[0].Name != definition.Name || nodes[0].Addr != definition.Addr {
t.Fatalf("hop nodes = %v, want global definition %q at %q", nodes, definition.Name, definition.Addr)
}
}
func TestRegister_GlobalNodeReferenceFromYAML(t *testing.T) {
const nodeName = "yaml-global-node"
const hopName = "yaml-global-hop"
t.Cleanup(func() {
registry.HopRegistry().Unregister(hopName)
registry.NodeRegistry().Unregister(nodeName)
})
cfg := &config.Config{}
err := cfg.Read(strings.NewReader(`
nodes:
- name: yaml-global-node
addr: global.example:8080
hops:
- name: yaml-global-hop
nodes:
- name: yaml-global-node
- name: inline-node
addr: inline.example:8081
`), "yaml")
if err != nil {
t.Fatalf("read YAML: %v", err)
}
if err := register(cfg); err != nil {
t.Fatalf("register: %v", err)
}
nodes := registry.HopRegistry().Get(hopName).(hop.NodeList).Nodes()
if len(nodes) != 2 {
t.Fatalf("hop nodes = %d, want 2", len(nodes))
}
if nodes[0].Name != nodeName || nodes[0].Addr != "global.example:8080" {
t.Fatalf("referenced node = %q at %q", nodes[0].Name, nodes[0].Addr)
}
if nodes[1].Name != "inline-node" || nodes[1].Addr != "inline.example:8081" {
t.Fatalf("inline node = %q at %q", nodes[1].Name, nodes[1].Addr)
}
}
func TestRegister_Chains(t *testing.T) {
r := registry.ChainRegistry()
name := "test-chain"
+6
View File
@@ -88,6 +88,12 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) {
continue
}
var err error
v, err = node_parser.ResolveConfig(v)
if err != nil {
return nil, err
}
// Build a merged metadata map for inheritance without mutating
// the original node config.
merged := make(map[string]any)
+77 -1
View File
@@ -4,13 +4,14 @@ import (
"io"
"testing"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
"github.com/go-gost/x/config/parsing"
xlogger "github.com/go-gost/x/logger"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry"
// Register connector and dialer implementations needed for node parsing.
_ "github.com/go-gost/x/connector/http"
@@ -185,6 +186,81 @@ func TestParseHop_WithNodeNilEntries(t *testing.T) {
}
}
func TestParseHop_GlobalNodeReference(t *testing.T) {
global := &config.NodeConfig{Name: "global-node", Addr: "example.com:8080"}
registry.NodeRegistry().Register(global.Name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
h, err := ParseHop(&config.HopConfig{
Name: "reference-hop",
Nodes: []*config.NodeConfig{{Name: global.Name}},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 || nodes[0].Name != global.Name || nodes[0].Addr != global.Addr {
t.Fatalf("referenced nodes = %v, want node %q at %q", nodes, global.Name, global.Addr)
}
}
func TestParseHop_GlobalNodeReferenceNotFound(t *testing.T) {
_, err := ParseHop(&config.HopConfig{
Name: "reference-hop",
Nodes: []*config.NodeConfig{{Name: "missing-global-node"}},
}, testLogger())
if err == nil {
t.Fatal("expected missing global node error")
}
}
func TestParseHop_NodeWithAdditionalFieldRemainsInline(t *testing.T) {
global := &config.NodeConfig{Name: "shared-name", Addr: "global.example:8080"}
registry.NodeRegistry().Register(global.Name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
h, err := ParseHop(&config.HopConfig{
Name: "inline-hop",
Nodes: []*config.NodeConfig{{
Name: global.Name,
Addr: "inline.example:8080",
}},
}, testLogger())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
nodes := h.(hop.NodeList).Nodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Addr != "inline.example:8080" {
t.Fatalf("node addr = %q, want inline node address", nodes[0].Addr)
}
}
func TestParseHop_GlobalNodeCreatesIndependentRuntimeNodes(t *testing.T) {
global := &config.NodeConfig{Name: "independent-node", Addr: "example.com:8080"}
registry.NodeRegistry().Register(global.Name, global)
t.Cleanup(func() { registry.NodeRegistry().Unregister(global.Name) })
parse := func(name string) *chain.Node {
h, err := ParseHop(&config.HopConfig{
Name: name, Nodes: []*config.NodeConfig{{Name: global.Name}},
}, testLogger())
if err != nil {
t.Fatalf("ParseHop: %v", err)
}
return h.(hop.NodeList).Nodes()[0]
}
if first, second := parse("hop-1"), parse("hop-2"); first == second {
t.Fatal("global node references must create independent runtime nodes")
}
if global.Connector != nil || global.Dialer != nil {
t.Fatal("parsing a reference must not mutate the global definition")
}
}
func TestParseHop_SockOptsMark(t *testing.T) {
h, err := ParseHop(&config.HopConfig{
Name: "sockopts-hop",
+59
View File
@@ -3,6 +3,7 @@ package node
import (
"fmt"
"net"
"reflect"
"regexp"
"strconv"
"strings"
@@ -37,6 +38,64 @@ const DefaultMatcherBodySize = 1 << 20 // 1MB
// when a node opts in to body matching.
const MaxMatcherBodySize = 10 << 20 // 10MB
// IsReference reports whether cfg contains only a non-empty name. Such a
// config references a definition from the top-level nodes section.
func IsReference(cfg *config.NodeConfig) bool {
if cfg == nil || strings.TrimSpace(cfg.Name) == "" {
return false
}
return reflect.DeepEqual(cfg, &config.NodeConfig{Name: cfg.Name})
}
// ResolveConfig resolves a name-only config through the global node registry
// and returns an independent copy of the definition. Inline configs are
// returned unchanged to preserve their existing parsing behavior.
func ResolveConfig(cfg *config.NodeConfig) (*config.NodeConfig, error) {
if cfg == nil {
return nil, nil
}
if !IsReference(cfg) {
return cfg, nil
}
name := cfg.Name
cfg = registry.NodeRegistry().Get(name)
if cfg == nil {
return nil, fmt.Errorf("node %q not found", name)
}
return cloneConfig(cfg), nil
}
// cloneConfig copies the fields that parsing may default or inherit. Other
// nested values are read-only during parsing and can safely remain shared.
func cloneConfig(cfg *config.NodeConfig) *config.NodeConfig {
c := *cfg
c.Bypasses = append([]string(nil), cfg.Bypasses...)
if cfg.Metadata != nil {
c.Metadata = make(map[string]any, len(cfg.Metadata))
for k, v := range cfg.Metadata {
c.Metadata[k] = v
}
}
if cfg.Connector != nil {
connector := *cfg.Connector
if cfg.Connector.TLS != nil {
tls := *cfg.Connector.TLS
connector.TLS = &tls
}
c.Connector = &connector
}
if cfg.Dialer != nil {
dialer := *cfg.Dialer
if cfg.Dialer.TLS != nil {
tls := *cfg.Dialer.TLS
dialer.TLS = &tls
}
c.Dialer = &dialer
}
return &c
}
// filterToMatcherRule converts a deprecated NodeFilterConfig (host/protocol/
// path) into an equivalent matcher DSL rule. Empty fields are omitted; an
// empty result means the node is unconditional. Path maps to PathPrefix
+5
View File
@@ -43,6 +43,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
Services: []*config.ServiceConfig{{Name: "s1"}},
Chains: []*config.ChainConfig{{Name: "c1"}},
Hops: []*config.HopConfig{{Name: "h1"}},
Nodes: []*config.NodeConfig{{Name: "n1", Addr: "n1.example:80"}},
Authers: []*config.AutherConfig{{Name: "a1"}},
Admissions: []*config.AdmissionConfig{{Name: "ad1"}},
Bypasses: []*config.BypassConfig{{Name: "b1"}},
@@ -63,6 +64,7 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
Services: []*config.ServiceConfig{{Name: "s2"}},
Chains: []*config.ChainConfig{{Name: "c2"}},
Hops: []*config.HopConfig{{Name: "h2"}},
Nodes: []*config.NodeConfig{{Name: "n2", Addr: "n2.example:80"}},
Authers: []*config.AutherConfig{{Name: "a2"}},
Admissions: []*config.AdmissionConfig{{Name: "ad2"}},
Bypasses: []*config.BypassConfig{{Name: "b2"}},
@@ -91,6 +93,9 @@ func TestMergeConfig_AppendsAllSlices(t *testing.T) {
if len(got.Hops) != 2 {
t.Fatal("hops not properly appended")
}
if len(got.Nodes) != 2 {
t.Fatal("nodes not properly appended")
}
if len(got.Authers) != 2 {
t.Fatal("authers not properly appended")
}
+1
View File
@@ -320,6 +320,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config {
Services: append(cfg1.Services, cfg2.Services...),
Chains: append(cfg1.Chains, cfg2.Chains...),
Hops: append(cfg1.Hops, cfg2.Hops...),
Nodes: append(cfg1.Nodes, cfg2.Nodes...),
Authers: append(cfg1.Authers, cfg2.Authers...),
Admissions: append(cfg1.Admissions, cfg2.Admissions...),
Bypasses: append(cfg1.Bypasses, cfg2.Bypasses...),
+8 -1
View File
@@ -484,6 +484,14 @@ func (p *chainHop) parseNode(r io.Reader) ([]*chain.Node, error) {
continue
}
name := nc.Name
nc, err := node_parser.ResolveConfig(nc)
if err != nil {
p.logger.Warnf("skip node %s: %v", name, err)
errs = append(errs, err)
continue
}
node, err := node_parser.ParseNode(p.options.name, nc, logger.Default())
if err != nil {
p.logger.Warnf("skip node %s: %v", nc.Name, err)
@@ -513,4 +521,3 @@ func (p *chainHop) Close() error {
}
return nil
}
+23 -3
View File
@@ -17,11 +17,12 @@ import (
"github.com/go-gost/core/logger"
"github.com/go-gost/core/metadata"
"github.com/go-gost/core/routing"
xmd "github.com/go-gost/x/metadata"
"github.com/go-gost/x/config"
xlogger "github.com/go-gost/x/logger"
xselector "github.com/go-gost/x/selector"
xrouting "github.com/go-gost/x/routing"
xmd "github.com/go-gost/x/metadata"
"github.com/go-gost/x/registry"
xrouting "github.com/go-gost/x/routing"
xselector "github.com/go-gost/x/selector"
)
// mustMatcher parses a matcher DSL rule, failing the test on error.
@@ -1035,6 +1036,24 @@ func TestParseNode_ValidJSON(t *testing.T) {
}
}
func TestParseNode_GlobalReference(t *testing.T) {
const name = "reloaded-global-node"
registry.NodeRegistry().Register(name, &config.NodeConfig{
Name: name,
Addr: "10.0.0.2:8080",
})
t.Cleanup(func() { registry.NodeRegistry().Unregister(name) })
ch := &chainHop{logger: xlogger.Nop(), options: options{name: "test-hop"}}
nodes, err := ch.parseNode(strings.NewReader(`[{"name":"reloaded-global-node"}]`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(nodes) != 1 || nodes[0].Name != name || nodes[0].Addr != "10.0.0.2:8080" {
t.Fatalf("unexpected nodes: %+v", nodes)
}
}
func TestParseNode_EmptyArray(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
r := strings.NewReader(`[]`)
@@ -1277,6 +1296,7 @@ func TestSelect_BackupPriority_LowerPriorityPrimaryWins(t *testing.T) {
t.Errorf("expected 'primary-low' (lower-priority primary beats higher-priority backup), got %q", node.Name)
}
}
// =============================================================================
// Interface satisfaction
// =============================================================================
+8 -1
View File
@@ -21,10 +21,11 @@ import (
"github.com/go-gost/core/recorder"
reg "github.com/go-gost/core/registry"
"github.com/go-gost/core/resolver"
"github.com/go-gost/core/router"
"github.com/go-gost/core/rewriter"
"github.com/go-gost/core/router"
"github.com/go-gost/core/sd"
"github.com/go-gost/core/service"
"github.com/go-gost/x/config"
"github.com/go-gost/x/limiter/quota"
xp2p "github.com/go-gost/x/p2p"
)
@@ -43,6 +44,7 @@ var (
serviceReg reg.Registry[service.Service] = new(serviceRegistry)
chainReg reg.Registry[chain.Chainer] = new(chainRegistry)
hopReg reg.Registry[hop.Hop] = new(hopRegistry)
nodeReg reg.Registry[*config.NodeConfig] = new(registry[*config.NodeConfig])
autherReg reg.Registry[auth.Authenticator] = new(autherRegistry)
admissionReg reg.Registry[admission.Admission] = new(admissionRegistry)
bypassReg reg.Registry[bypass.Bypass] = new(bypassRegistry)
@@ -164,6 +166,11 @@ func HopRegistry() reg.Registry[hop.Hop] {
return hopReg
}
// NodeRegistry returns the global registry of reusable node configurations.
func NodeRegistry() reg.Registry[*config.NodeConfig] {
return nodeReg
}
// AutherRegistry returns the global registry of authenticator instances.
func AutherRegistry() reg.Registry[auth.Authenticator] {
return autherReg
+8 -1
View File
@@ -33,6 +33,7 @@ import (
"github.com/go-gost/core/sd"
"github.com/go-gost/core/selector"
"github.com/go-gost/core/service"
"github.com/go-gost/x/config"
)
// --- Base registry[T] tests ---
@@ -213,6 +214,9 @@ func TestGlobalRegistryAccessors(t *testing.T) {
if HopRegistry() == nil {
t.Error("HopRegistry() returned nil")
}
if NodeRegistry() == nil {
t.Error("NodeRegistry() returned nil")
}
if AutherRegistry() == nil {
t.Error("AutherRegistry() returned nil")
}
@@ -280,6 +284,9 @@ func TestGlobalRegistrySingleton(t *testing.T) {
if HopRegistry() != HopRegistry() {
t.Error("HopRegistry not singleton")
}
if NodeRegistry() != NodeRegistry() {
t.Error("NodeRegistry not singleton")
}
if AutherRegistry() != AutherRegistry() {
t.Error("AutherRegistry not singleton")
}
@@ -1047,4 +1054,4 @@ var _ reg.Registry[router.Router] = (*routerRegistry)(nil)
var _ reg.Registry[sd.SD] = (*sdRegistry)(nil)
var _ reg.Registry[observer.Observer] = (*observerRegistry)(nil)
var _ reg.Registry[logger.Logger] = (*loggerRegistry)(nil)
var _ reg.Registry[*config.NodeConfig] = NodeRegistry()