refactor(hop): normalize deprecated node filter into matcher at parse time

Fold the deprecated per-node filter (host/protocol/path) into an equivalent
matcher DSL rule during config parsing, so hop.Select keeps a single
filtering path (routing.Matcher). Legacy filter configs stay accepted.

- filterToMatcherRule() maps host->Host(), protocol->Proto(), path->PathPrefix();
  a leading-dot host emits `Host(x) || Host(.x)` to preserve the legacy
  apex+subdomain coverage. Converted rules get priority=-1 to keep the
  always-through-selector behavior of the legacy path.
- Remove isEligible/checkHost/checkProtocol/checkPath from x/hop.
  core/ chain.NodeFilterSettings is left untouched (dead but harmless).
- Migrate hop tests to matchers; add TestFilterToMatcherRule.
This commit is contained in:
ginuerzh
2026-08-26 20:26:26 +08:00
parent 4ea4efaba9
commit 6ad7269644
4 changed files with 170 additions and 384 deletions
+70 -44
View File
@@ -36,6 +36,35 @@ const DefaultMatcherBodySize = 1 << 20 // 1MB
// when a node opts in to body matching.
const MaxMatcherBodySize = 10 << 20 // 10MB
// 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
// (the legacy filter is a prefix match, not exact).
func filterToMatcherRule(filter *config.NodeFilterConfig) string {
if filter == nil {
return ""
}
var parts []string
if host := filter.Host; host != "" {
// A leading-dot host (from `*.example.com` or `.example.com`) matches
// the apex AND its subdomains in the legacy filter (HasSuffix on the
// dot-stripped form), but matcher Host(.x) only matches subdomains.
// Emit both to preserve apex coverage.
if strings.HasPrefix(host, ".") {
parts = append(parts, "Host(`"+host[1:]+"`) || Host(`"+host+"`)")
} else {
parts = append(parts, "Host(`"+host+"`)")
}
}
if prot := filter.Protocol; prot != "" {
parts = append(parts, "Proto(`"+prot+"`)")
}
if path := filter.Path; path != "" {
parts = append(parts, "PathPrefix(`"+path+"`)")
}
return strings.Join(parts, " && ")
}
// ParseNode converts a NodeConfig into a *chain.Node. It resolves the
// connector and dialer from their registries, applies TLS settings, extracts
// metadata-driven options (so_mark, interface, netns, proxy protocol), sets up
@@ -221,56 +250,53 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No
chain.NetworkNodeOption(cfg.Network),
}
if filter := cfg.Filter; filter != nil {
// convert *.example.com to .example.com
// convert *example.com to example.com
host := filter.Host
if strings.HasPrefix(host, "*") {
host = host[1:]
if !strings.HasPrefix(host, ".") {
host = "." + host
}
}
var (
rule string
priority int
bodySize int
)
settings := &chain.NodeFilterSettings{
Protocol: filter.Protocol,
Host: host,
Path: filter.Path,
}
opts = append(opts, chain.NodeFilterOption(settings))
// Deprecated filter: normalize into an equivalent matcher DSL rule so the
// runtime keeps a single filtering logic. Explicit -1 priority opts out of
// auto-priority and the priority short-circuit, preserving the legacy
// filter behavior (always through the selector).
if filter := cfg.Filter; filter != nil {
rule = filterToMatcherRule(filter)
priority = -1
bodySize = DefaultMatcherBodySize
}
if cfg.Matcher != nil {
priority := cfg.Matcher.Priority
if rule := strings.TrimSpace(cfg.Matcher.Rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, cfg.Matcher.Rule)
// Priority 0 means "use default": automatically set to the
// rule length so longer (more specific) rules outrank shorter
// ones. Use a negative priority to opt out of this behavior
// and always go through the selector.
if priority == 0 {
priority = len(cfg.Matcher.Rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
bodySize := cfg.Matcher.BodySize
if bodySize <= 0 {
bodySize = DefaultMatcherBodySize
} else if bodySize > MaxMatcherBodySize {
bodySize = MaxMatcherBodySize
}
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
opts = append(opts, chain.PriorityNodeOption(priority))
priority = cfg.Matcher.Priority
rule = cfg.Matcher.Rule
bodySize = cfg.Matcher.BodySize
}
if rule = strings.TrimSpace(rule); rule != "" {
if matcher, err := routing.NewMatcher(rule); err == nil {
log.Debugf("new matcher for node %s with rule %s", cfg.Name, rule)
// Priority 0 means "use default": automatically set to the
// rule length so longer (more specific) rules outrank shorter
// ones. Use a negative priority to opt out of this behavior
// and always go through the selector.
if priority == 0 {
priority = len(rule)
}
opts = append(opts, chain.MatcherNodeOption(matcher))
} else {
log.Error(err)
priority = -1
}
}
if bodySize <= 0 {
bodySize = DefaultMatcherBodySize
} else if bodySize > MaxMatcherBodySize {
bodySize = MaxMatcherBodySize
}
opts = append(opts, chain.MatcherBodySizeNodeOption(bodySize))
opts = append(opts, chain.PriorityNodeOption(priority))
if cfg.HTTP != nil {
settings := &chain.HTTPNodeSettings{
Host: cfg.HTTP.Host,
+28
View File
@@ -501,3 +501,31 @@ func TestParseNode_DeprecatedFilterFields(t *testing.T) {
t.Fatal("expected non-nil node")
}
}
func TestFilterToMatcherRule(t *testing.T) {
tests := []struct {
name string
filter *config.NodeFilterConfig
want string
}{
{"nil", nil, ""},
{"all empty", &config.NodeFilterConfig{}, ""},
{"host only", &config.NodeFilterConfig{Host: "example.com"}, "Host(`example.com`)"},
{"wildcard host", &config.NodeFilterConfig{Host: ".example.com"},
"Host(`example.com`) || Host(`.example.com`)"},
{"protocol only", &config.NodeFilterConfig{Protocol: "ssh"}, "Proto(`ssh`)"},
{"path only", &config.NodeFilterConfig{Path: "/api"}, "PathPrefix(`/api`)"},
{"combined", &config.NodeFilterConfig{
Host: ".example.com",
Protocol: "http",
Path: "/api",
}, "Host(`example.com`) || Host(`.example.com`) && Proto(`http`) && PathPrefix(`/api`)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filterToMatcherRule(tt.filter); got != tt.want {
t.Errorf("filterToMatcherRule(%v) = %q, want %q", tt.filter, got, tt.want)
}
})
}
}
+12 -75
View File
@@ -9,9 +9,7 @@ import (
"encoding/json"
"errors"
"io"
"net"
"sort"
"strings"
"sync"
"time"
@@ -171,13 +169,12 @@ func (p *chainHop) Nodes() []*chain.Node {
// Selection pipeline:
//
// 1. Hop-level bypass — entire hop skipped if bypass matches (addr/host)
// 2. Per-node filter (pool) — each candidate must pass at least one gate:
// a. routing.Matcher — boolean expression (Host/Protocol/Method/Path/
// Query/Header/Body). Match → non-zero Priority.
// b. isEligible fallback — when no Matcher set, checks node Options.Filter:
// checkHost(Host), checkProtocol(Protocol),
// checkPath(Path prefix).
// Nodes that fail all gates or hit a node-level bypass are excluded.
// 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.
// 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).
@@ -346,23 +343,11 @@ func isAllBackup(nodes []*chain.Node) bool {
return len(nodes) > 0
}
func (p *chainHop) isEligible(node *chain.Node, opts *hop.SelectOptions) bool {
if node == nil {
return false
}
if node.Options().Filter == nil {
return true
}
if !p.checkHost(opts.Host, node) || !p.checkProtocol(opts.Protocol, node) || !p.checkPath(opts.Path, node) {
return false
}
return true
}
// nodeMatches reports whether node passes the per-node bypass and
// matcher/eligible gates for the given selection options. Shared by the
// candidate-pool scan and the all-failed last-resort fallback.
// nodeMatches reports whether node passes the per-node bypass and matcher
// gates for the given selection options. Shared by the candidate-pool scan
// and the all-failed last-resort fallback. A node with no matcher is an
// unconditional candidate (legacy filter config is normalized to a matcher
// at parse time).
func (p *chainHop) nodeMatches(ctx context.Context, node *chain.Node, opts *hop.SelectOptions) bool {
if node == nil {
return false
@@ -385,55 +370,7 @@ func (p *chainHop) nodeMatches(ctx context.Context, node *chain.Node, opts *hop.
}
return matcher.Match(&req)
}
return p.isEligible(node, opts)
}
func (p *chainHop) checkHost(host string, node *chain.Node) bool {
var vhost string
if filter := node.Options().Filter; filter != nil {
vhost = filter.Host
}
if vhost == "" { // backup node
return true
}
if host == "" {
return false
}
if v, _, _ := net.SplitHostPort(host); v != "" {
host = v
}
if vhost == host || vhost[0] == '.' && strings.HasSuffix(host, vhost[1:]) {
return true
}
return false
}
func (p *chainHop) checkProtocol(protocol string, node *chain.Node) bool {
var prot string
if filter := node.Options().Filter; filter != nil {
prot = filter.Protocol
}
if prot == "" {
return true
}
return prot == protocol
}
func (p *chainHop) checkPath(path string, node *chain.Node) bool {
var pathFilter string
if filter := node.Options().Filter; filter != nil {
pathFilter = filter.Path
}
if pathFilter == "" {
return true
}
return strings.HasPrefix(path, pathFilter)
return true
}
func (p *chainHop) periodReload(ctx context.Context) error {
+60 -265
View File
@@ -20,9 +20,20 @@ import (
xmd "github.com/go-gost/x/metadata"
xlogger "github.com/go-gost/x/logger"
xselector "github.com/go-gost/x/selector"
xrouting "github.com/go-gost/x/routing"
"github.com/go-gost/x/registry"
)
// mustMatcher parses a matcher DSL rule, failing the test on error.
func mustMatcher(t *testing.T, rule string) routing.Matcher {
t.Helper()
m, err := xrouting.NewMatcher(rule)
if err != nil {
t.Fatalf("NewMatcher(%q): %v", rule, err)
}
return m
}
// --- Mock types ---
type testBypass struct {
@@ -668,259 +679,12 @@ func TestSelect_NilNodesSkipped(t *testing.T) {
}
// =============================================================================
// isEligible tests
// Select with matcher integration tests
// =============================================================================
func TestIsEligible_NilNode(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
if ch.isEligible(nil, &hop.SelectOptions{}) {
t.Error("expected false for nil node")
}
}
func TestIsEligible_NoFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.isEligible(n, &hop.SelectOptions{}) {
t.Error("expected true when no filter")
}
}
func TestIsEligible_HostMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if !ch.isEligible(n, &hop.SelectOptions{Host: "example.com"}) {
t.Error("expected true for matching host")
}
}
func TestIsEligible_HostMismatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if ch.isEligible(n, &hop.SelectOptions{Host: "other.com"}) {
t.Error("expected false for mismatching host")
}
}
func TestIsEligible_ProtocolMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
if !ch.isEligible(n, &hop.SelectOptions{Protocol: "http"}) {
t.Error("expected true for matching protocol")
}
}
func TestIsEligible_ProtocolMismatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
if ch.isEligible(n, &hop.SelectOptions{Protocol: "socks5"}) {
t.Error("expected false for mismatching protocol")
}
}
func TestIsEligible_PathMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if !ch.isEligible(n, &hop.SelectOptions{Path: "/api/v1/users"}) {
t.Error("expected true for matching path prefix")
}
}
func TestIsEligible_PathMismatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if ch.isEligible(n, &hop.SelectOptions{Path: "/other/route"}) {
t.Error("expected false for mismatching path")
}
}
func TestIsEligible_AllFiltersMustPass(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{
Host: "example.com",
Protocol: "http",
}
if ch.isEligible(n, &hop.SelectOptions{Host: "example.com", Protocol: "socks5"}) {
t.Error("expected false: host matches but protocol doesn't")
}
}
// =============================================================================
// checkHost tests
// =============================================================================
func TestCheckHost_EmptyFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.checkHost("anything.com", n) {
t.Error("expected true when no host filter")
}
}
func TestCheckHost_ExactMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if !ch.checkHost("example.com", n) {
t.Error("expected true for exact host match")
}
}
func TestCheckHost_WildcardMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: ".example.com"}
if !ch.checkHost("sub.example.com", n) {
t.Error("expected true for wildcard host match")
}
}
func TestCheckHost_WildcardNoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: ".example.com"}
if ch.checkHost("other.net", n) {
t.Error("expected false for non-matching wildcard")
}
}
func TestCheckHost_EmptyInputHost(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if ch.checkHost("", n) {
t.Error("expected false when input host is empty and filter is set")
}
}
func TestCheckHost_StripPort(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if !ch.checkHost("example.com:443", n) {
t.Error("expected true after stripping port")
}
}
func TestCheckHost_IPWithPort(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "192.168.1.1"}
if !ch.checkHost("192.168.1.1:3128", n) {
t.Error("expected true for IP with port")
}
}
func TestCheckHost_IPv6WithPort(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "::1"}
if !ch.checkHost("[::1]:8080", n) {
t.Error("expected true for bracketed IPv6 with port")
}
}
func TestCheckHost_NoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
if ch.checkHost("other.com", n) {
t.Error("expected false for non-matching host")
}
}
// =============================================================================
// checkProtocol tests
// =============================================================================
func TestCheckProtocol_EmptyFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.checkProtocol("http", n) {
t.Error("expected true when no protocol filter")
}
}
func TestCheckProtocol_Match(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
if !ch.checkProtocol("http", n) {
t.Error("expected true for matching protocol")
}
}
func TestCheckProtocol_NoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Protocol: "socks5"}
if ch.checkProtocol("http", n) {
t.Error("expected false for non-matching protocol")
}
}
// =============================================================================
// checkPath tests
// =============================================================================
func TestCheckPath_EmptyFilter(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
if !ch.checkPath("/any/path", n) {
t.Error("expected true when no path filter")
}
}
func TestCheckPath_PrefixMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if !ch.checkPath("/api/v1/users", n) {
t.Error("expected true for prefix match")
}
}
func TestCheckPath_ExactMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if !ch.checkPath("/api", n) {
t.Error("expected true for exact match (prefix of self)")
}
}
func TestCheckPath_NoMatch(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if ch.checkPath("/other", n) {
t.Error("expected false for non-prefix match")
}
}
func TestCheckPath_ShorterInput(t *testing.T) {
ch := &chainHop{logger: xlogger.Nop()}
n := chain.NewNode("n1", "127.0.0.1:8080")
n.Options().Filter = &chain.NodeFilterSettings{Path: "/api"}
if ch.checkPath("/ap", n) {
t.Error("expected false when input is shorter than filter prefix")
}
}
// =============================================================================
// Select with filter integration tests
// =============================================================================
func TestSelect_FilterHost_Match(t *testing.T) {
func TestSelect_MatcherHost_Match(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
n1.Options().Matcher = mustMatcher(t, "Host(`example.com`)")
h := newTestHop(NodeOption(n1))
defer h.Close()
@@ -930,9 +694,9 @@ func TestSelect_FilterHost_Match(t *testing.T) {
}
}
func TestSelect_FilterHost_NoMatch(t *testing.T) {
func TestSelect_MatcherHost_NoMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: "example.com"}
n1.Options().Matcher = mustMatcher(t, "Host(`example.com`)")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
@@ -946,9 +710,9 @@ func TestSelect_FilterHost_NoMatch(t *testing.T) {
}
}
func TestSelect_FilterProtocol_Match(t *testing.T) {
func TestSelect_MatcherProtocol_Match(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
n1.Options().Matcher = mustMatcher(t, "Proto(`http`)")
h := newTestHop(NodeOption(n1))
defer h.Close()
@@ -958,9 +722,9 @@ func TestSelect_FilterProtocol_Match(t *testing.T) {
}
}
func TestSelect_FilterProtocol_NoMatch(t *testing.T) {
func TestSelect_MatcherProtocol_NoMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Protocol: "http"}
n1.Options().Matcher = mustMatcher(t, "Proto(`http`)")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
@@ -974,6 +738,39 @@ func TestSelect_FilterProtocol_NoMatch(t *testing.T) {
}
}
func TestSelect_MatcherWildcardHost_MatchesApexAndSubdomain(t *testing.T) {
// filter `.example.com` (legacy wildcard) matches apex AND subdomains;
// the converter emits `Host(example.com) || Host(.example.com)`.
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Matcher = mustMatcher(t, "Host(`example.com`) || Host(`.example.com`)")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
for _, host := range []string{"example.com", "sub.example.com", "a.b.example.com"} {
node := h.Select(context.Background(), hop.HostSelectOption(host))
if node == nil || node.Name != "n1" {
t.Errorf("expected n1 for host %q, got %v", host, node)
}
}
}
func TestSelect_MatcherWildcardHost_NoSubdomainMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Matcher = mustMatcher(t, "Host(`example.com`) || Host(`.example.com`)")
n2 := chain.NewNode("n2", "127.0.0.1:9090")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
node := h.Select(context.Background(), hop.HostSelectOption("other.net"))
if node == nil {
t.Fatal("expected node, got nil")
}
if node.Name != "n2" {
t.Errorf("expected 'n2' (n1 host mismatch), got %q", node.Name)
}
}
// =============================================================================
// Select with select options
// =============================================================================
@@ -1420,14 +1217,13 @@ func TestSelect_OnlyNegativePriorities(t *testing.T) {
}
// =============================================================================
// Select with backup node (empty host filter means backup)
// Select with catch-all node (no matcher = unconditional candidate)
// =============================================================================
func TestSelect_BackupNode_HostFilterEmpty(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: ""}
func TestSelect_CatchAllNode_TakesOverOnNoMatch(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080") // no matcher: catch-all
n2 := chain.NewNode("n2", "127.0.0.1:9090")
n2.Options().Filter = &chain.NodeFilterSettings{Host: "specific.com"}
n2.Options().Matcher = mustMatcher(t, "Host(`specific.com`)")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()
@@ -1436,15 +1232,14 @@ func TestSelect_BackupNode_HostFilterEmpty(t *testing.T) {
t.Fatal("expected node, got nil")
}
if node.Name != "n1" {
t.Errorf("expected 'n1' (backup), got %q", node.Name)
t.Errorf("expected 'n1' (catch-all), got %q", node.Name)
}
}
func TestSelect_SpecificHostNodeWins(t *testing.T) {
n1 := chain.NewNode("n1", "127.0.0.1:8080")
n1.Options().Filter = &chain.NodeFilterSettings{Host: ""}
n1 := chain.NewNode("n1", "127.0.0.1:8080") // no matcher: catch-all
n2 := chain.NewNode("n2", "127.0.0.1:9090")
n2.Options().Filter = &chain.NodeFilterSettings{Host: "specific.com"}
n2.Options().Matcher = mustMatcher(t, "Host(`specific.com`)")
h := newTestHop(NodeOption(n1, n2))
defer h.Close()