diff --git a/config/config.go b/config/config.go index f8ca224..4ff8eed 100644 --- a/config/config.go +++ b/config/config.go @@ -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"` diff --git a/config/loader/loader.go b/config/loader/loader.go index 55b9a22..4dde538 100644 --- a/config/loader/loader.go +++ b/config/loader/loader.go @@ -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] diff --git a/config/loader/loader_test.go b/config/loader/loader_test.go index eee7b94..77d6a31 100644 --- a/config/loader/loader_test.go +++ b/config/loader/loader_test.go @@ -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" diff --git a/config/parsing/hop/parse.go b/config/parsing/hop/parse.go index 359c417..29e6724 100644 --- a/config/parsing/hop/parse.go +++ b/config/parsing/hop/parse.go @@ -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) diff --git a/config/parsing/hop/parse_test.go b/config/parsing/hop/parse_test.go index 0eaffbd..1645138 100644 --- a/config/parsing/hop/parse_test.go +++ b/config/parsing/hop/parse_test.go @@ -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", diff --git a/config/parsing/node/parse.go b/config/parsing/node/parse.go index 52489f1..8edcb34 100644 --- a/config/parsing/node/parse.go +++ b/config/parsing/node/parse.go @@ -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 diff --git a/config/parsing/parser/parse_test.go b/config/parsing/parser/parse_test.go index b72a86f..f2dc964 100644 --- a/config/parsing/parser/parse_test.go +++ b/config/parsing/parser/parse_test.go @@ -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") } diff --git a/config/parsing/parser/parser.go b/config/parsing/parser/parser.go index 37a7fa9..aec7670 100644 --- a/config/parsing/parser/parser.go +++ b/config/parsing/parser/parser.go @@ -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...), diff --git a/hop/hop.go b/hop/hop.go index eebf3f2..33d4ae6 100644 --- a/hop/hop.go +++ b/hop/hop.go @@ -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 } - diff --git a/hop/hop_test.go b/hop/hop_test.go index d1395fb..085156f 100644 --- a/hop/hop_test.go +++ b/hop/hop_test.go @@ -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 // ============================================================================= diff --git a/registry/registry.go b/registry/registry.go index 4c15c50..a9d4ba0 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -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 diff --git a/registry/registry_test.go b/registry/registry_test.go index c5dea84..6ce76a6 100644 --- a/registry/registry_test.go +++ b/registry/registry_test.go @@ -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()