diff --git a/config/parsing/hop/parse.go b/config/parsing/hop/parse.go index 29e6724..28a2893 100644 --- a/config/parsing/hop/parse.go +++ b/config/parsing/hop/parse.go @@ -91,6 +91,10 @@ func ParseHop(cfg *config.HopConfig, log logger.Logger) (hop.Hop, error) { var err error v, err = node_parser.ResolveConfig(v) if err != nil { + // Fail the whole hop: static config is validated at startup, + // so a dangling reference is a hard error. (Contrast with + // chainHop.parseNode, which warns and skips bad nodes to + // keep hot-reloaded hops resilient.) return nil, err } diff --git a/config/parsing/node/parse.go b/config/parsing/node/parse.go index 21876ee..88bf92c 100644 --- a/config/parsing/node/parse.go +++ b/config/parsing/node/parse.go @@ -3,7 +3,6 @@ package node import ( "fmt" "net" - "reflect" "regexp" "strconv" "strings" @@ -40,11 +39,20 @@ 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. +// Any additional field, even a zero-valued one set explicitly (e.g. an +// empty but non-nil connector), makes the config inline. +// NOTE: keep this in sync with NodeConfig — a new field must be added here, +// otherwise a config setting only that field would misresolve as a reference. func IsReference(cfg *config.NodeConfig) bool { if cfg == nil || strings.TrimSpace(cfg.Name) == "" { return false } - return reflect.DeepEqual(cfg, &config.NodeConfig{Name: cfg.Name}) + return cfg.Addr == "" && cfg.Network == "" && cfg.Bypass == "" && + len(cfg.Bypasses) == 0 && cfg.Resolver == "" && cfg.Hosts == "" && + cfg.Connector == nil && cfg.Dialer == nil && + cfg.Interface == "" && cfg.Netns == "" && cfg.SockOpts == nil && + cfg.Filter == nil && cfg.Matcher == nil && cfg.HTTP == nil && + cfg.TLS == nil && cfg.Probe == nil && len(cfg.Metadata) == 0 } // ResolveConfig resolves a name-only config through the global node registry @@ -58,7 +66,7 @@ func ResolveConfig(cfg *config.NodeConfig) (*config.NodeConfig, error) { return cfg, nil } - name := cfg.Name + name := strings.TrimSpace(cfg.Name) cfg = registry.NodeRegistry().Get(name) if cfg == nil { return nil, fmt.Errorf("node %q not found", name) @@ -66,8 +74,11 @@ func ResolveConfig(cfg *config.NodeConfig) (*config.NodeConfig, error) { 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. +// cloneConfig returns a deep copy of cfg so that parsing (hop-level +// inheritance, connector/dialer type defaults, TLS ServerName defaulting) +// only ever mutates the copy, never the shared global definition. +// NOTE: keep this in sync with NodeConfig — a new reference-type field +// must be cloned here, otherwise hops would share it with the registry. func cloneConfig(cfg *config.NodeConfig) *config.NodeConfig { c := *cfg c.Bypasses = append([]string(nil), cfg.Bypasses...) @@ -79,23 +90,107 @@ func cloneConfig(cfg *config.NodeConfig) *config.NodeConfig { } if cfg.Connector != nil { connector := *cfg.Connector - if cfg.Connector.TLS != nil { - tls := *cfg.Connector.TLS - connector.TLS = &tls + connector.Auth = cloneAuth(cfg.Connector.Auth) + connector.TLS = cloneTLSConfig(cfg.Connector.TLS) + if cfg.Connector.Metadata != nil { + connector.Metadata = make(map[string]any, len(cfg.Connector.Metadata)) + for k, v := range cfg.Connector.Metadata { + connector.Metadata[k] = v + } } c.Connector = &connector } if cfg.Dialer != nil { dialer := *cfg.Dialer - if cfg.Dialer.TLS != nil { - tls := *cfg.Dialer.TLS - dialer.TLS = &tls + dialer.Auth = cloneAuth(cfg.Dialer.Auth) + dialer.TLS = cloneTLSConfig(cfg.Dialer.TLS) + if cfg.Dialer.Metadata != nil { + dialer.Metadata = make(map[string]any, len(cfg.Dialer.Metadata)) + for k, v := range cfg.Dialer.Metadata { + dialer.Metadata[k] = v + } } c.Dialer = &dialer } + if cfg.SockOpts != nil { + sockOpts := *cfg.SockOpts + c.SockOpts = &sockOpts + } + if cfg.Filter != nil { + filter := *cfg.Filter + c.Filter = &filter + } + if cfg.Matcher != nil { + matcher := *cfg.Matcher + c.Matcher = &matcher + } + if cfg.HTTP != nil { + http := *cfg.HTTP + http.Header = cloneStringMap(cfg.HTTP.Header) + http.RequestHeader = cloneStringMap(cfg.HTTP.RequestHeader) + http.ResponseHeader = cloneStringMap(cfg.HTTP.ResponseHeader) + http.Rewrite = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.Rewrite...) + http.RewriteURL = append([]config.HTTPURLRewriteConfig(nil), cfg.HTTP.RewriteURL...) + http.RewriteBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteBody...) + http.RewriteRequestBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteRequestBody...) + http.RewriteResponseBody = append([]config.HTTPBodyRewriteConfig(nil), cfg.HTTP.RewriteResponseBody...) + http.RewriteRequestHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteRequestHeader...) + http.RewriteResponseHeader = append([]config.HTTPHeaderRewriteConfig(nil), cfg.HTTP.RewriteResponseHeader...) + http.Auth = cloneAuth(cfg.HTTP.Auth) + c.HTTP = &http + } + if cfg.TLS != nil { + tls := *cfg.TLS + if cfg.TLS.Options != nil { + options := *cfg.TLS.Options + options.CipherSuites = append([]string(nil), cfg.TLS.Options.CipherSuites...) + options.ALPN = append([]string(nil), cfg.TLS.Options.ALPN...) + tls.Options = &options + } + c.TLS = &tls + } + if cfg.Probe != nil { + probe := *cfg.Probe + probe.HTTPHeaders = cloneStringMap(cfg.Probe.HTTPHeaders) + c.Probe = &probe + } return &c } +func cloneAuth(cfg *config.AuthConfig) *config.AuthConfig { + if cfg == nil { + return nil + } + auth := *cfg + return &auth +} + +func cloneTLSConfig(cfg *config.TLSConfig) *config.TLSConfig { + if cfg == nil { + return nil + } + tls := *cfg + tls.ServerNames = append([]string(nil), cfg.ServerNames...) + if cfg.Options != nil { + options := *cfg.Options + options.CipherSuites = append([]string(nil), cfg.Options.CipherSuites...) + options.ALPN = append([]string(nil), cfg.Options.ALPN...) + tls.Options = &options + } + return &tls +} + +func cloneStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + c := make(map[string]string, len(m)) + for k, v := range m { + c[k] = v + } + 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/node/parse_test.go b/config/parsing/node/parse_test.go index 26cdb09..bbe53c3 100644 --- a/config/parsing/node/parse_test.go +++ b/config/parsing/node/parse_test.go @@ -8,6 +8,7 @@ import ( "github.com/go-gost/x/config" "github.com/go-gost/x/config/parsing" xlogger "github.com/go-gost/x/logger" + "github.com/go-gost/x/registry" // Register connector and dialer implementations needed for node parsing. _ "github.com/go-gost/x/connector/http" @@ -574,3 +575,101 @@ func TestFilterToMatcherRule(t *testing.T) { }) } } + +func TestIsReference(t *testing.T) { + tests := []struct { + name string + cfg *config.NodeConfig + want bool + }{ + {"nil", nil, false}, + {"empty name", &config.NodeConfig{}, false}, + {"blank name", &config.NodeConfig{Name: " "}, false}, + {"name only", &config.NodeConfig{Name: "shared"}, true}, + {"name and addr", &config.NodeConfig{Name: "shared", Addr: "example.com:80"}, false}, + {"name and network", &config.NodeConfig{Name: "shared", Network: "tcp"}, false}, + {"name and empty-but-non-nil connector", &config.NodeConfig{Name: "shared", Connector: &config.ConnectorConfig{}}, false}, + {"name and empty bypasses slice", &config.NodeConfig{Name: "shared", Bypasses: []string{}}, true}, + {"name and matcher", &config.NodeConfig{Name: "shared", Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsReference(tt.cfg); got != tt.want { + t.Errorf("IsReference(%+v) = %v, want %v", tt.cfg, got, tt.want) + } + }) + } +} + +func TestResolveConfig_TrimsName(t *testing.T) { + const name = "spaced-global-node" + registry.NodeRegistry().Register(name, &config.NodeConfig{Name: name, Addr: "example.com:8080"}) + t.Cleanup(func() { registry.NodeRegistry().Unregister(name) }) + + got, err := ResolveConfig(&config.NodeConfig{Name: " " + name + " "}) + if err != nil { + t.Fatalf("ResolveConfig: %v", err) + } + if got == nil || got.Addr != "example.com:8080" { + t.Fatalf("resolved = %+v, want addr %q", got, "example.com:8080") + } +} + +func TestResolveConfig_DeepCopyIsolation(t *testing.T) { + const name = "rich-global-node" + global := &config.NodeConfig{ + Name: name, + Addr: "example.com:8080", + Metadata: map[string]any{"k": "v"}, + Connector: &config.ConnectorConfig{ + Type: "http", + Auth: &config.AuthConfig{Username: "u"}, + Metadata: map[string]any{"ck": "cv"}, + TLS: &config.TLSConfig{ServerName: "sni.example", Options: &config.TLSOptions{ALPN: []string{"h2"}}}, + }, + Dialer: &config.DialerConfig{ + Type: "tcp", + Metadata: map[string]any{"dk": "dv"}, + }, + Matcher: &config.NodeMatcherConfig{Rule: "Host(`x`)"}, + HTTP: &config.HTTPNodeConfig{ + Host: "h", + RequestHeader: map[string]string{"A": "b"}, + Auth: &config.AuthConfig{Username: "hu"}, + }, + TLS: &config.TLSNodeConfig{ServerName: "tls.example", Options: &config.TLSOptions{MinVersion: "1.2"}}, + Probe: &config.ProbeConfig{Type: "tcp", Addr: "example.com:80", HTTPHeaders: map[string]string{"H": "v"}}, + } + registry.NodeRegistry().Register(name, global) + t.Cleanup(func() { registry.NodeRegistry().Unregister(name) }) + + got, err := ResolveConfig(&config.NodeConfig{Name: name}) + if err != nil { + t.Fatalf("ResolveConfig: %v", err) + } + + // Mutate every nested value on the copy; the global must not change. + got.Metadata["k"] = "mut" + got.Connector.Auth.Username = "mut" + got.Connector.Metadata["ck"] = "mut" + got.Connector.TLS.Options.ALPN[0] = "mut" + got.Dialer.Metadata["dk"] = "mut" + got.Matcher.Rule = "mut" + got.HTTP.RequestHeader["A"] = "mut" + got.HTTP.Auth.Username = "mut" + got.TLS.Options.MinVersion = "mut" + got.Probe.HTTPHeaders["H"] = "mut" + + if global.Metadata["k"] != "v" || + global.Connector.Auth.Username != "u" || + global.Connector.Metadata["ck"] != "cv" || + global.Connector.TLS.Options.ALPN[0] != "h2" || + global.Dialer.Metadata["dk"] != "dv" || + global.Matcher.Rule != "Host(`x`)" || + global.HTTP.RequestHeader["A"] != "b" || + global.HTTP.Auth.Username != "hu" || + global.TLS.Options.MinVersion != "1.2" || + global.Probe.HTTPHeaders["H"] != "v" { + t.Fatalf("global definition was mutated via the resolved copy: %+v", global) + } +} diff --git a/hop/hop.go b/hop/hop.go index 33d4ae6..6035ba9 100644 --- a/hop/hop.go +++ b/hop/hop.go @@ -20,29 +20,29 @@ import ( "github.com/go-gost/core/routing" "github.com/go-gost/core/selector" "github.com/go-gost/x/config" - xs "github.com/go-gost/x/selector" - mdutil "github.com/go-gost/x/metadata/util" node_parser "github.com/go-gost/x/config/parsing/node" "github.com/go-gost/x/internal/loader" xlogger "github.com/go-gost/x/logger" + mdutil "github.com/go-gost/x/metadata/util" + xs "github.com/go-gost/x/selector" ) type options struct { - name string - nodes []*chain.Node - bypass bypass.Bypass - selector selector.Selector[*chain.Node] + name string + nodes []*chain.Node + bypass bypass.Bypass + selector selector.Selector[*chain.Node] // failMaxFails / failTimeout mirror the FailFilter config used by the // selector. Node selection consults them via selector.IsFailed to skip // nodes currently marked failed (FailFilter itself no-ops on a single // node, so the marker is checked here instead). failMaxFails int failTimeout time.Duration - fileLoader loader.Loader - redisLoader loader.Loader - httpLoader loader.Loader - period time.Duration - logger logger.Logger + fileLoader loader.Loader + redisLoader loader.Loader + httpLoader loader.Loader + period time.Duration + logger logger.Logger } // Option configures a hop. @@ -170,17 +170,17 @@ func (p *chainHop) Nodes() []*chain.Node { // // 1. Hop-level bypass — entire hop skipped if bypass matches (addr/host) // 2. Per-node matcher (pool) — each candidate must pass the gate: -// routing.Matcher — boolean expression (Host/Protocol/Method/ -// Path/Query/Header/Body). Match → non-zero -// Priority. Nodes without a matcher are -// unconditional candidates. +// routing.Matcher — boolean expression (Host/Protocol/Method/ +// Path/Query/Header/Body). Match → non-zero +// Priority. Nodes without a matcher are +// unconditional candidates. // Nodes that fail the gate or hit a node-level bypass are excluded. // 3. Priority short-circuit — if the top node has strictly higher Priority -// (>0) than the rest and no backup flag is present, -// it wins directly (selector skipped). +// (>0) than the rest and no backup flag is present, +// it wins directly (selector skipped). // 4. Selector — Filters (FailFilter, BackupFilter) cull the pool, -// then Strategy (roundRobin/random/fifo/hash/parallel) -// picks one. +// then Strategy (roundRobin/random/fifo/hash/parallel) +// picks one. // // Returns nil when no node survives all stages. func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.Node { @@ -201,12 +201,12 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain. // Track priority stats and backup presence during the scan so we can // skip a separate sort/scan in the common all-equal-priority case. var ( - nodes []*chain.Node - maxPriority int - maxPriCount int - maxPriNode *chain.Node - hasBackup bool - sawFailed bool + nodes []*chain.Node + maxPriority int + maxPriCount int + maxPriNode *chain.Node + hasBackup bool + sawFailed bool ) for _, node := range p.Nodes() { if node == nil { @@ -487,6 +487,9 @@ func (p *chainHop) parseNode(r io.Reader) ([]*chain.Node, error) { name := nc.Name nc, err := node_parser.ResolveConfig(nc) if err != nil { + // Warn and skip: file/Redis/HTTP reloads must stay resilient + // to a single bad entry. (Contrast with ParseHop, which + // fails the whole hop on a dangling reference at startup.) p.logger.Warnf("skip node %s: %v", name, err) errs = append(errs, err) continue