mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user