diff --git a/config/config.go b/config/config.go index edc8546f..68dbb1fa 100644 --- a/config/config.go +++ b/config/config.go @@ -155,7 +155,7 @@ type SelectorConfig struct { type AdmissionConfig struct { Name string `json:"name"` - // DEPRECATED by whitelist since beta.4 + // Deprecated: use whitelist instead Reverse bool `yaml:",omitempty" json:"reverse,omitempty"` Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"` Matchers []string `yaml:",omitempty" json:"matchers,omitempty"` @@ -168,7 +168,7 @@ type AdmissionConfig struct { type BypassConfig struct { Name string `json:"name"` - // DEPRECATED by whitelist since beta.4 + // Deprecated: use whitelist instead Reverse bool `yaml:",omitempty" json:"reverse,omitempty"` Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"` Matchers []string `yaml:",omitempty" json:"matchers,omitempty"` @@ -347,7 +347,7 @@ type HandlerConfig struct { } type ForwarderConfig struct { - // DEPRECATED by hop field + // Deprecated: use hop instead Name string `yaml:",omitempty" json:"name,omitempty"` // the referenced hop name Hop string `yaml:",omitempty" json:"hop,omitempty"` @@ -361,18 +361,20 @@ type ForwardNodeConfig struct { Network string `yaml:",omitempty" json:"network,omitempty"` Bypass string `yaml:",omitempty" json:"bypass,omitempty"` Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"` - // DEPRECATED by filter.protocol + // Deprecated: use matcher instead Protocol string `yaml:",omitempty" json:"protocol,omitempty"` - // DEPRECATED by filter.host + // Deprecated: use matcher instead Host string `yaml:",omitempty" json:"host,omitempty"` - // DEPRECATED by filter.path + // Deprecated: use matcher instead Path string `yaml:",omitempty" json:"path,omitempty"` - // DEPRECATED by http.auth - Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"` - Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"` - HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"` - TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"` - Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"` + // Deprecated: use matcher instead + Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"` + Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"` + // Deprecated: use http.auth instead + Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"` + HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"` + TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"` + Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"` } type HTTPURLRewriteConfig struct { @@ -393,6 +395,11 @@ type NodeFilterConfig struct { Path string `yaml:",omitempty" json:"path,omitempty"` } +type NodeMatcherConfig struct { + Rule string `yaml:",omitempty" json:"rule,omitempty"` + Priority int `yaml:",omitempty" json:"priority,omitempty"` +} + type HTTPNodeConfig struct { // rewrite host header Host string `yaml:",omitempty" json:"host,omitempty"` @@ -435,9 +442,9 @@ type SockOptsConfig struct { type ServiceConfig struct { Name string `json:"name"` Addr string `yaml:",omitempty" json:"addr,omitempty"` - // DEPRECATED by metadata.interface since beta.5 + // Deprecated: use metadata.interface instead Interface string `yaml:",omitempty" json:"interface,omitempty"` - // DEPRECATED by metadata.so_mark since beta.5 + // Deprecated: use metadata.so_mark instead SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"` Admission string `yaml:",omitempty" json:"admission,omitempty"` Admissions []string `yaml:",omitempty" json:"admissions,omitempty"` @@ -510,22 +517,24 @@ type HopConfig struct { } type NodeConfig struct { - Name string `json:"name"` - Addr string `yaml:",omitempty" json:"addr,omitempty"` - Network string `yaml:",omitempty" json:"network,omitempty"` - Bypass string `yaml:",omitempty" json:"bypass,omitempty"` - Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"` - Resolver string `yaml:",omitempty" json:"resolver,omitempty"` - Hosts string `yaml:",omitempty" json:"hosts,omitempty"` - Connector *ConnectorConfig `yaml:",omitempty" json:"connector,omitempty"` - Dialer *DialerConfig `yaml:",omitempty" json:"dialer,omitempty"` - Interface string `yaml:",omitempty" json:"interface,omitempty"` - Netns string `yaml:",omitempty" json:"netns,omitempty"` - SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"` - Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"` - HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"` - TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"` - Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"` + Name string `json:"name"` + Addr string `yaml:",omitempty" json:"addr,omitempty"` + Network string `yaml:",omitempty" json:"network,omitempty"` + Bypass string `yaml:",omitempty" json:"bypass,omitempty"` + Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"` + Resolver string `yaml:",omitempty" json:"resolver,omitempty"` + Hosts string `yaml:",omitempty" json:"hosts,omitempty"` + Connector *ConnectorConfig `yaml:",omitempty" json:"connector,omitempty"` + Dialer *DialerConfig `yaml:",omitempty" json:"dialer,omitempty"` + Interface string `yaml:",omitempty" json:"interface,omitempty"` + Netns string `yaml:",omitempty" json:"netns,omitempty"` + SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"` + // Deprecated: use matcher instead + Filter *NodeFilterConfig `yaml:",omitempty" json:"filter,omitempty"` + Matcher *NodeMatcherConfig `yaml:",omitempty" json:"matcher,omitempty"` + HTTP *HTTPNodeConfig `yaml:",omitempty" json:"http,omitempty"` + TLS *TLSNodeConfig `yaml:",omitempty" json:"tls,omitempty"` + Metadata map[string]any `yaml:",omitempty" json:"metadata,omitempty"` } type Config struct { diff --git a/config/parsing/node/parse.go b/config/parsing/node/parse.go index 413107f6..20feab35 100644 --- a/config/parsing/node/parse.go +++ b/config/parsing/node/parse.go @@ -22,6 +22,7 @@ import ( mdx "github.com/go-gost/x/metadata" mdutil "github.com/go-gost/x/metadata/util" "github.com/go-gost/x/registry" + "github.com/go-gost/x/routing" ) func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.Node, error) { @@ -175,6 +176,25 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No opts = append(opts, chain.NodeFilterOption(settings)) } + 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) + if priority == 0 { + priority = len(cfg.Matcher.Rule) + } + opts = append(opts, chain.MatcherNodeOption(matcher)) + } else { + log.Error(err) + priority = -1 + } + } + + opts = append(opts, chain.PriorityNodeOption(priority)) + } + if cfg.HTTP != nil { settings := &chain.HTTPNodeSettings{ Host: cfg.HTTP.Host, diff --git a/config/parsing/service/parse.go b/config/parsing/service/parse.go index 3dc94e0d..152915a4 100644 --- a/config/parsing/service/parse.go +++ b/config/parsing/service/parse.go @@ -375,6 +375,7 @@ func parseForwarder(cfg *config.ForwarderConfig, log logger.Logger) (hop.Hop, er Bypass: node.Bypass, Bypasses: node.Bypasses, Filter: filter, + Matcher: node.Matcher, HTTP: httpCfg, TLS: node.TLS, Metadata: node.Metadata, diff --git a/go.mod b/go.mod index dd8b5965..865c7c25 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 - github.com/go-gost/core v0.1.9 + github.com/go-gost/core v0.1.10 github.com/go-gost/gosocks4 v0.0.1 github.com/go-gost/gosocks5 v0.4.2 github.com/go-gost/plugin v0.1.1 @@ -34,16 +34,20 @@ require ( github.com/sirupsen/logrus v1.8.1 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.1.0 github.com/vishvananda/netns v0.0.4 + github.com/vulcand/predicate v1.2.0 github.com/xtaci/kcp-go/v5 v5.6.5 github.com/xtaci/smux v1.5.31 github.com/xtaci/tcpraw v1.2.25 github.com/yl2chen/cidranger v1.0.2 github.com/zalando/go-keyring v0.2.4 golang.org/x/crypto v0.27.0 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 golang.org/x/net v0.29.0 golang.org/x/sys v0.25.0 + golang.org/x/text v0.18.0 golang.org/x/time v0.5.0 golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478 google.golang.org/grpc v1.67.1 @@ -63,6 +67,7 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/coreos/go-iptables v0.5.0 // indirect github.com/danieljoos/wincred v1.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect @@ -75,7 +80,9 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/pprof v0.0.0-20240528025155-186aa0362fba // indirect + github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/reedsolomon v1.11.8 // indirect @@ -91,6 +98,7 @@ require ( github.com/pion/transport/v2 v2.0.2 // indirect github.com/pion/udp/v2 v2.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect @@ -111,10 +119,9 @@ require ( go.uber.org/mock v0.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/term v0.24.0 // indirect golang.org/x/tools v0.23.0 // indirect golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect diff --git a/go.sum b/go.sum index b050ff07..1548cd5d 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= @@ -50,8 +51,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/go-gost/core v0.1.9 h1:qrKzhE9X2+zuNc8d4hRv4I8ZklwHAfja5HngrLucM2c= -github.com/go-gost/core v0.1.9/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A= +github.com/go-gost/core v0.1.10 h1:sONgU+BRbDE5owFh7IeJ1KBdG7JH6KmCAW0FpLoId2I= +github.com/go-gost/core v0.1.10/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A= github.com/go-gost/gosocks4 v0.0.1 h1:+k1sec8HlELuQV7rWftIkmy8UijzUt2I6t+iMPlGB2s= github.com/go-gost/gosocks4 v0.0.1/go.mod h1:3B6L47HbU/qugDg4JnoFPHgJXE43Inz8Bah1QaN9qCc= github.com/go-gost/gosocks5 v0.4.2 h1:IianxHTkACPqCwiOAT3MHoMdSUl+SEPSRu1ikawC1Pc= @@ -110,8 +111,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf h1:C1GPyPJrOlJlIrcaBBiBpDsqZena2Ks8spa5xZqr1XQ= +github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf/go.mod h1:zXqxTI6jXDdKnlf8s+nT+3c8LrwUEy3yNpO4XJL90lA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -197,6 +202,7 @@ github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1H github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM= github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601 h1:XU9hik0exChEmY92ALW4l9WnDodxLVS9yOSNh2SizaQ= github.com/shadowsocks/shadowsocks-go v0.0.0-20200409064450-3e585ff90601/go.mod h1:mttDPaeLm87u74HMrP+n2tugXvIKWcwff/cqSX0lehY= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= @@ -219,6 +225,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -243,6 +250,8 @@ github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYp github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/vulcand/predicate v1.2.0 h1:uFsW1gcnnR7R+QTID+FVcs0sSYlIGntoGOTb3rQJt50= +github.com/vulcand/predicate v1.2.0/go.mod h1:VipoNYXny6c8N381zGUWkjuuNHiRbeAZhE7Qm9c+2GA= github.com/xtaci/kcp-go/v5 v5.6.5 h1:oxGZNobj3OddrLzwdJYnR/waNgwrL98u02u0DWNHE3k= github.com/xtaci/kcp-go/v5 v5.6.5/go.mod h1:Qy3Zf2tWTdFdEs0E8JvhrX+39r5UDZoYac8anvud7/Q= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= @@ -267,6 +276,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= @@ -290,6 +300,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= @@ -360,6 +371,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go. google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= diff --git a/hop/hop.go b/hop/hop.go index c98623b0..a7cc4817 100644 --- a/hop/hop.go +++ b/hop/hop.go @@ -14,6 +14,7 @@ import ( "github.com/go-gost/core/chain" "github.com/go-gost/core/hop" "github.com/go-gost/core/logger" + "github.com/go-gost/core/routing" "github.com/go-gost/core/selector" "github.com/go-gost/x/config" node_parser "github.com/go-gost/x/config/parsing/node" @@ -132,18 +133,16 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain. opt(&options) } + log := p.options.logger + // hop level bypass if p.options.bypass != nil && p.options.bypass.Contains(ctx, options.Network, options.Addr, bypass.WithHostOpton(options.Host)) { return nil } - filters := p.filterByHost(options.Host, p.Nodes()...) - filters = p.filterByProtocol(options.Protocol, filters...) - filters = p.filterByPath(options.Path, filters...) - var nodes []*chain.Node - for _, node := range filters { + for _, node := range p.Nodes() { if node == nil { continue } @@ -153,11 +152,42 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain. continue } + if matcher := node.Options().Matcher; matcher != nil { + req := routing.Request{ + ClientIP: options.ClientIP, + Host: options.Host, + Protocol: options.Protocol, + Method: options.Method, + Path: options.Path, + Query: options.Query, + Header: options.Header, + } + if !matcher.Match(&req) { + continue + } + log.Debugf("node %s match request %s %s, priority %d", node.Name, req.Protocol, req.Host, node.Options().Priority) + } else { + if !p.isEligible(node, &options) { + continue + } + } + nodes = append(nodes, node) } if len(nodes) == 0 { return nil } + if len(nodes) == 1 { + return nodes[0] + } + + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].Options().Priority > nodes[j].Options().Priority + }) + + if nodes[0].Options().Priority > 0 { + return nodes[0] + } if s := p.options.selector; s != nil { return s.Select(ctx, nodes...) @@ -165,126 +195,66 @@ func (p *chainHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain. return nodes[0] } -func (p *chainHop) filterByHost(host string, nodes ...*chain.Node) (filters []*chain.Node) { - if host == "" || len(nodes) == 0 { - return nodes +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 +} + +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 } - p.options.logger.Debugf("filter by host: %s", host) - - found := false - for _, node := range nodes { - if node == nil { - continue - } - - var vhost string - if filter := node.Options().Filter; filter != nil { - vhost = filter.Host - } - if vhost == "" { // backup node - if !found { - filters = append(filters, node) - } - continue - } - - if vhost == host || - vhost[0] == '.' && strings.HasSuffix(host, vhost[1:]) { - if !found { // clear all backup nodes when matched node found - filters = nil - } - filters = append(filters, node) - found = true - continue - } + if vhost == host || vhost[0] == '.' && strings.HasSuffix(host, vhost[1:]) { + return true } - return + return false } -func (p *chainHop) filterByProtocol(protocol string, nodes ...*chain.Node) (filters []*chain.Node) { - if protocol == "" || len(nodes) == 0 { - return nodes +func (p *chainHop) checkProtocol(protocol string, node *chain.Node) bool { + var prot string + if filter := node.Options().Filter; filter != nil { + prot = filter.Protocol } - - p.options.logger.Debugf("filter by protocol: %s", protocol) - found := false - for _, node := range nodes { - if node == nil { - continue - } - - var prot string - if filter := node.Options().Filter; filter != nil { - prot = filter.Protocol - } - if prot == "" { - if !found { - filters = append(filters, node) - } - continue - } - - if prot == protocol { - if !found { - filters = nil - } - filters = append(filters, node) - found = true - continue - } + if prot == "" { + return true } - - return + return prot == protocol } -func (p *chainHop) filterByPath(path string, nodes ...*chain.Node) (filters []*chain.Node) { - if path == "" || len(nodes) == 0 { - return nodes +func (p *chainHop) checkPath(path string, node *chain.Node) bool { + var pathFilter string + if filter := node.Options().Filter; filter != nil { + pathFilter = filter.Path } - p.options.logger.Debugf("filter by path: %s", path) - - sort.SliceStable(nodes, func(i, j int) bool { - filter1 := nodes[i].Options().Filter - if filter1 == nil { - return false - } - filter2 := nodes[j].Options().Filter - if filter2 == nil { - return true - } - return len(filter1.Path) > len(filter2.Path) - }) - - found := false - for _, node := range nodes { - var pathFilter string - if filter := node.Options().Filter; filter != nil { - pathFilter = filter.Path - } - if pathFilter == "" { - if !found { - filters = append(filters, node) - } - continue - } - - if strings.HasPrefix(path, pathFilter) { - if !found { - filters = nil - } - filters = append(filters, node) - break - } + if pathFilter == "" { + return true } - return + return strings.HasPrefix(path, pathFilter) } func (p *chainHop) periodReload(ctx context.Context) error { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a7043c32..a4993636 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -56,7 +56,6 @@ func NewGRPCConn(addr string, opts *Options) (*grpc.ClientConn, error) { grpc.WithConnectParams(grpc.ConnectParams{ Backoff: backoff.DefaultConfig, }), - grpc.FailOnNonTempDialError(true), } if opts.TLSConfig != nil { grpcOpts = append(grpcOpts, @@ -69,7 +68,7 @@ func NewGRPCConn(addr string, opts *Options) (*grpc.ClientConn, error) { if opts.Token != "" { grpcOpts = append(grpcOpts, grpc.WithPerRPCCredentials(&rpcCredentials{token: opts.Token})) } - return grpc.Dial(addr, grpcOpts...) + return grpc.NewClient(addr, grpcOpts...) } type rpcCredentials struct { diff --git a/internal/util/forwarder/sniffer.go b/internal/util/forwarder/sniffer.go index b1bc5fe3..a9b8f582 100644 --- a/internal/util/forwarder/sniffer.go +++ b/internal/util/forwarder/sniffer.go @@ -251,9 +251,13 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho } if ho.Hop != nil { node = ho.Hop.Select(ctx, - hop.HostSelectOption(host), + hop.ClientIPSelectOption(net.ParseIP(ro.ClientIP)), hop.ProtocolSelectOption(sniffing.ProtoHTTP), + hop.HostSelectOption(host), + hop.MethodSelectOption(req.Method), hop.PathSelectOption(req.URL.Path), + hop.QuerySelectOption(req.URL.Query()), + hop.HeaderSelectOption(req.Header), ) } if node == nil { diff --git a/routing/matcher.go b/routing/matcher.go new file mode 100644 index 00000000..26fa9293 --- /dev/null +++ b/routing/matcher.go @@ -0,0 +1,464 @@ +package routing + +import ( + "fmt" + "net" + "net/http" + "regexp" + "strings" + "unicode/utf8" + + "github.com/go-gost/core/routing" + "github.com/go-gost/x/routing/rules" + "golang.org/x/exp/slices" +) + +var ( + defaultParser rules.Parser +) + +func init() { + var matchers []string + for matcher := range httpFuncs { + matchers = append(matchers, matcher) + } + + parser, err := rules.NewParser(matchers) + if err != nil { + panic(err) + } + + defaultParser = parser +} + +type matcher struct { + // matchers tree structure reflecting the rule. + tree matchersTree +} + +func NewMatcher(rule string) (routing.Matcher, error) { + parse, err := defaultParser.Parse(rule) + if err != nil { + return nil, fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + buildTree, ok := parse.(rules.TreeBuilder) + if !ok { + return nil, fmt.Errorf("error while parsing rule %s", rule) + } + + var matchers matchersTree + err = matchers.addRule(buildTree(), httpFuncs) + if err != nil { + return nil, fmt.Errorf("error while adding rule %s: %w", rule, err) + } + + return &matcher{ + tree: matchers, + }, nil +} + +func (m *matcher) Match(req *routing.Request) bool { + if m == nil { + return false + } + + return m.tree.match(req) +} + +// matchersTree represents the matchers tree structure. +type matchersTree struct { + // matcher is a matcher func used to match HTTP request properties. + // If matcher is not nil, it means that this matcherTree is a leaf of the tree. + // It is therefore mutually exclusive with left and right. + matcher func(*routing.Request) bool + // operator to combine the evaluation of left and right leaves. + operator string + // Mutually exclusive with matcher. + left *matchersTree + right *matchersTree +} + +func (m *matchersTree) match(req *routing.Request) bool { + if m == nil { + // This should never happen as it should have been detected during parsing. + return false + } + + if m.matcher != nil { + return m.matcher(req) + } + + switch m.operator { + case "or": + return m.left.match(req) || m.right.match(req) + case "and": + return m.left.match(req) && m.right.match(req) + default: + // This should never happen as it should have been detected during parsing. + return false + } +} + +type matcherFuncs map[string]func(*matchersTree, ...string) error + +func (m *matchersTree) addRule(rule *rules.Tree, funcs matcherFuncs) error { + switch rule.Matcher { + case "and", "or": + m.operator = rule.Matcher + m.left = &matchersTree{} + err := m.left.addRule(rule.RuleLeft, funcs) + if err != nil { + return fmt.Errorf("error while adding rule %s: %w", rule.Matcher, err) + } + + m.right = &matchersTree{} + return m.right.addRule(rule.RuleRight, funcs) + default: + err := rules.CheckRule(rule) + if err != nil { + return fmt.Errorf("error while checking rule %s: %w", rule.Matcher, err) + } + + err = funcs[rule.Matcher](m, rule.Value...) + if err != nil { + return fmt.Errorf("error while adding rule %s: %w", rule.Matcher, err) + } + + if rule.Not { + matcherFunc := m.matcher + m.matcher = func(req *routing.Request) bool { + return !matcherFunc(req) + } + } + } + + return nil +} + +var httpFuncs = map[string]func(*matchersTree, ...string) error{ + "ClientIP": expectNParameters(clientIP, 1), + "Proto": expectNParameters(proto, 1), + "Host": expectNParameters(host, 1), + "HostRegexp": expectNParameters(hostRegexp, 1), + "Method": expectNParameters(method, 1), + "Path": expectNParameters(path, 1), + "PathRegexp": expectNParameters(pathRegexp, 1), + "PathPrefix": expectNParameters(pathPrefix, 1), + "Header": expectNParameters(header, 1, 2), + "HeaderRegexp": expectNParameters(headerRegexp, 1, 2), + "Query": expectNParameters(query, 1, 2), + "QueryRegexp": expectNParameters(queryRegexp, 1, 2), +} + +func expectNParameters(fn func(*matchersTree, ...string) error, n ...int) func(*matchersTree, ...string) error { + return func(tree *matchersTree, s ...string) error { + if !slices.Contains(n, len(s)) { + return fmt.Errorf("unexpected number of parameters; got %d, expected one of %v", len(s), n) + } + + return fn(tree, s...) + } +} + +func clientIP(tree *matchersTree, clientIP ...string) error { + ip := net.ParseIP(clientIP[0]) + + var ipNet *net.IPNet + if ip == nil { + _, ipNet, _ = net.ParseCIDR(clientIP[0]) + } + if ip == nil && ipNet == nil { + return fmt.Errorf("invalid value %q for ClientIP matcher", clientIP[0]) + } + + tree.matcher = func(req *routing.Request) bool { + if req.ClientIP == nil { + return false + } + + if ip != nil { + return ip.Equal(req.ClientIP) + } + + return ipNet.Contains(req.ClientIP) + } + + return nil +} + +func proto(tree *matchersTree, protos ...string) error { + proto := strings.ToLower(protos[0]) + + tree.matcher = func(req *routing.Request) bool { + // logger.Default().Debugf("proto: %s %s", proto, req.Protocol) + return proto == req.Protocol + } + + return nil +} + +func method(tree *matchersTree, methods ...string) error { + method := strings.ToUpper(methods[0]) + + tree.matcher = func(req *routing.Request) bool { + return method == req.Method + } + + return nil +} + +func host(tree *matchersTree, hosts ...string) error { + host := hosts[0] + + if !IsASCII(host) { + return fmt.Errorf("invalid value %q for Host matcher, non-ASCII characters are not allowed", host) + } + + host = strings.ToLower(strings.TrimSpace(host)) + + if strings.HasPrefix(host, "*") { + host = host[1:] + if !strings.HasPrefix(host, ".") { + host = "." + host + } + } + + tree.matcher = func(req *routing.Request) bool { + // logger.Default().Debugf("host: %s %s", host, req.Host) + reqHost := strings.ToLower(strings.TrimSpace(parseHost(req.Host))) + if len(reqHost) == 0 { + return false + } + + if reqHost == host { + return true + } + + if host[0] == '.' && strings.HasSuffix(reqHost, host[1:]) { + return true + } + + return false + } + + return nil +} + +func hostRegexp(tree *matchersTree, hosts ...string) error { + host := hosts[0] + + if !IsASCII(host) { + return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) + } + + re, err := regexp.Compile(host) + if err != nil { + return fmt.Errorf("compiling HostRegexp matcher: %w", err) + } + + tree.matcher = func(req *routing.Request) bool { + // logger.Default().Debugf("hostRegexp: %s %s", host, req.Host) + return re.MatchString(strings.ToLower(strings.TrimSpace(parseHost(req.Host)))) + } + + return nil +} + +func path(tree *matchersTree, paths ...string) error { + path := paths[0] + + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + + tree.matcher = func(req *routing.Request) bool { + return req.Path == path + } + + return nil +} + +func pathRegexp(tree *matchersTree, paths ...string) error { + path := paths[0] + + re, err := regexp.Compile(path) + if err != nil { + return fmt.Errorf("compiling PathPrefix matcher: %w", err) + } + + tree.matcher = func(req *routing.Request) bool { + return re.MatchString(req.Path) + } + + return nil +} + +func pathPrefix(tree *matchersTree, paths ...string) error { + path := paths[0] + + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + + tree.matcher = func(req *routing.Request) bool { + return strings.HasPrefix(req.Path, path) + } + + return nil +} + +func header(tree *matchersTree, headers ...string) error { + key := http.CanonicalHeaderKey(headers[0]) + + var value string + var hasValue bool + if len(headers) == 2 { + value = headers[1] + hasValue = true + } + + tree.matcher = func(req *routing.Request) bool { + if req.Header == nil { + return false + } + + values, ok := req.Header[key] + if !ok { + return false + } + + if !hasValue { + return true + } + + for _, headerValue := range values { + if headerValue == value { + return true + } + } + + return false + } + + return nil +} + +func headerRegexp(tree *matchersTree, headers ...string) error { + if len(headers) == 1 { + return header(tree, headers...) + } + + key, value := http.CanonicalHeaderKey(headers[0]), headers[1] + + re, err := regexp.Compile(value) + if err != nil { + return fmt.Errorf("compiling HeaderRegexp matcher: %w", err) + } + + tree.matcher = func(req *routing.Request) bool { + if req.Header == nil { + return false + } + + for _, headerValue := range req.Header[key] { + if re.MatchString(headerValue) { + return true + } + } + + return false + } + + return nil +} + +func query(tree *matchersTree, queries ...string) error { + key := queries[0] + + var value string + var hasValue bool + if len(queries) == 2 { + value = queries[1] + hasValue = true + } + + tree.matcher = func(req *routing.Request) bool { + if req.Query == nil { + return false + } + + values, ok := req.Query[key] + if !ok { + return false + } + + if !hasValue { + return true + } + + return slices.Contains(values, value) + } + + return nil +} + +func queryRegexp(tree *matchersTree, queries ...string) error { + if len(queries) == 1 { + return query(tree, queries...) + } + + key, value := queries[0], queries[1] + + re, err := regexp.Compile(value) + if err != nil { + return fmt.Errorf("compiling QueryRegexp matcher: %w", err) + } + + tree.matcher = func(req *routing.Request) bool { + if req.Query == nil { + return false + } + + values, ok := req.Query[key] + if !ok { + return false + } + + idx := slices.IndexFunc(values, func(value string) bool { + return re.MatchString(value) + }) + + return idx >= 0 + } + + return nil +} + +// IsASCII checks if the given string contains only ASCII characters. +func IsASCII(s string) bool { + for i := range len(s) { + if s[i] >= utf8.RuneSelf { + return false + } + } + + return true +} + +func parseHost(addr string) string { + if !strings.Contains(addr, ":") { + // IPv4 without port or empty address + return addr + } + + // IPv4 with port or IPv6 + host, _, err := net.SplitHostPort(addr) + if err != nil { + if addr[0] == '[' && addr[len(addr)-1] == ']' { + return addr[1 : len(addr)-1] + } + return addr + } + return host +} diff --git a/routing/rules/parser.go b/routing/rules/parser.go new file mode 100644 index 00000000..50fcfe40 --- /dev/null +++ b/routing/rules/parser.go @@ -0,0 +1,141 @@ +package rules + +import ( + "fmt" + "strings" + + "github.com/vulcand/predicate" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + and = "and" + or = "or" +) + +type Parser = predicate.Parser + +// TreeBuilder defines the type for a Tree builder. +type TreeBuilder func() *Tree + +// Tree represents the rules' tree structure. +type Tree struct { + Matcher string + Not bool + Value []string + RuleLeft *Tree + RuleRight *Tree +} + +// NewParser constructs a parser for the given matchers. +func NewParser(matchers []string) (predicate.Parser, error) { + parserFuncs := make(map[string]interface{}) + + for _, matcherName := range matchers { + fn := func(value ...string) TreeBuilder { + return func() *Tree { + return &Tree{ + Matcher: matcherName, + Value: value, + } + } + } + parserFuncs[matcherName] = fn + parserFuncs[strings.ToLower(matcherName)] = fn + parserFuncs[strings.ToUpper(matcherName)] = fn + parserFuncs[cases.Title(language.Und).String(strings.ToLower(matcherName))] = fn + } + + return predicate.NewParser(predicate.Def{ + Operators: predicate.Operators{ + AND: andFunc, + OR: orFunc, + NOT: notFunc, + }, + Functions: parserFuncs, + }) +} + +func andFunc(left, right TreeBuilder) TreeBuilder { + return func() *Tree { + return &Tree{ + Matcher: and, + RuleLeft: left(), + RuleRight: right(), + } + } +} + +func orFunc(left, right TreeBuilder) TreeBuilder { + return func() *Tree { + return &Tree{ + Matcher: or, + RuleLeft: left(), + RuleRight: right(), + } + } +} + +func invert(t *Tree) *Tree { + switch t.Matcher { + case or: + t.Matcher = and + t.RuleLeft = invert(t.RuleLeft) + t.RuleRight = invert(t.RuleRight) + case and: + t.Matcher = or + t.RuleLeft = invert(t.RuleLeft) + t.RuleRight = invert(t.RuleRight) + default: + t.Not = !t.Not + } + + return t +} + +func notFunc(elem TreeBuilder) TreeBuilder { + return func() *Tree { + return invert(elem()) + } +} + +// ParseMatchers returns the subset of matchers in the Tree matching the given matchers. +func (tree *Tree) ParseMatchers(matchers []string) []string { + switch tree.Matcher { + case and, or: + return append(tree.RuleLeft.ParseMatchers(matchers), tree.RuleRight.ParseMatchers(matchers)...) + default: + for _, matcher := range matchers { + if tree.Matcher == matcher { + return lower(tree.Value) + } + } + + return nil + } +} + +// CheckRule validates the given rule. +func CheckRule(rule *Tree) error { + if len(rule.Value) == 0 { + return fmt.Errorf("no args for matcher %s", rule.Matcher) + } + + for _, v := range rule.Value { + if len(v) == 0 { + return fmt.Errorf("empty args for matcher %s, %v", rule.Matcher, rule.Value) + } + } + + return nil +} + +func lower(slice []string) []string { + var lowerStrings []string + for _, value := range slice { + lowerStrings = append(lowerStrings, strings.ToLower(value)) + } + + return lowerStrings +} diff --git a/routing/rules/parser_test.go b/routing/rules/parser_test.go new file mode 100644 index 00000000..b8aed5d5 --- /dev/null +++ b/routing/rules/parser_test.go @@ -0,0 +1,299 @@ +package rules + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testTree = Tree + CheckErr +type testTree struct { + Matcher string + Not bool + Value []string + RuleLeft *testTree + RuleRight *testTree + + // CheckErr allow knowing if a Tree has a rule error. + CheckErr bool +} + +func TestRuleMatch(t *testing.T) { + matchers := []string{"m"} + testCases := []struct { + desc string + rule string + tree testTree + matchers []string + values []string + expectParseErr bool + }{ + { + desc: "No rule", + rule: "", + expectParseErr: true, + }, + { + desc: "No matcher in rule", + rule: "m", + expectParseErr: true, + }, + { + desc: "No value in rule", + rule: "m()", + tree: testTree{ + Matcher: "m", + Value: []string{}, + CheckErr: true, + }, + }, + { + desc: "Empty value in rule", + rule: "m(``)", + tree: testTree{ + Matcher: "m", + Value: []string{""}, + CheckErr: true, + }, + matchers: []string{"m"}, + values: []string{""}, + }, + { + desc: "One value in rule with and", + rule: "m(`1`) &&", + expectParseErr: true, + }, + { + desc: "One value in rule with or", + rule: "m(`1`) ||", + expectParseErr: true, + }, + { + desc: "One value in rule with missing back tick", + rule: "m(`1)", + expectParseErr: true, + }, + { + desc: "One value in rule with missing opening parenthesis", + rule: "m(`1`))", + expectParseErr: true, + }, + { + desc: "One value in rule with missing closing parenthesis", + rule: "(m(`1`)", + expectParseErr: true, + }, + { + desc: "One value in rule", + rule: "m(`1`)", + tree: testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + matchers: []string{"m"}, + values: []string{"1"}, + }, + { + desc: "One value in rule with superfluous parenthesis", + rule: "(m(`1`))", + tree: testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + matchers: []string{"m"}, + values: []string{"1"}, + }, + { + desc: "Rule with CAPS matcher", + rule: "M(`1`)", + tree: testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + matchers: []string{"m"}, + values: []string{"1"}, + }, + { + desc: "Invalid matcher in rule", + rule: "w(`1`)", + expectParseErr: true, + }, + { + desc: "Invalid matchers", + rule: "m(`1`)", + tree: testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + matchers: []string{"not-m"}, + }, + { + desc: "Two value in rule", + rule: "m(`1`, `2`)", + tree: testTree{ + Matcher: "m", + Value: []string{"1", "2"}, + }, + matchers: []string{"m"}, + values: []string{"1", "2"}, + }, + { + desc: "Not one value in rule", + rule: "!m(`1`)", + tree: testTree{ + Matcher: "m", + Not: true, + Value: []string{"1"}, + }, + matchers: []string{"m"}, + values: []string{"1"}, + }, + { + desc: "Two value in rule with and", + rule: "m(`1`) && m(`2`)", + tree: testTree{ + Matcher: "and", + CheckErr: true, + RuleLeft: &testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + RuleRight: &testTree{ + Matcher: "m", + Value: []string{"2"}, + }, + }, + matchers: []string{"m"}, + values: []string{"1", "2"}, + }, + { + desc: "Two value in rule with or", + rule: "m(`1`) || m(`2`)", + tree: testTree{ + Matcher: "or", + CheckErr: true, + RuleLeft: &testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + RuleRight: &testTree{ + Matcher: "m", + Value: []string{"2"}, + }, + }, + matchers: []string{"m"}, + values: []string{"1", "2"}, + }, + { + desc: "Two value in rule with and negated", + rule: "!(m(`1`) && m(`2`))", + tree: testTree{ + Matcher: "or", + CheckErr: true, + RuleLeft: &testTree{ + Matcher: "m", + Not: true, + Value: []string{"1"}, + }, + RuleRight: &testTree{ + Matcher: "m", + Not: true, + Value: []string{"2"}, + }, + }, + matchers: []string{"m"}, + values: []string{"1", "2"}, + }, + { + desc: "Two value in rule with or negated", + rule: "!(m(`1`) || m(`2`))", + tree: testTree{ + Matcher: "and", + CheckErr: true, + RuleLeft: &testTree{ + Matcher: "m", + Not: true, + Value: []string{"1"}, + }, + RuleRight: &testTree{ + Matcher: "m", + Not: true, + Value: []string{"2"}, + }, + }, + matchers: []string{"m"}, + values: []string{"1", "2"}, + }, + { + desc: "No value in rule", + rule: "m(`1`) && m()", + tree: testTree{ + Matcher: "and", + CheckErr: true, + RuleLeft: &testTree{ + Matcher: "m", + Value: []string{"1"}, + }, + RuleRight: &testTree{ + Matcher: "m", + Value: []string{}, + CheckErr: true, + }, + }, + matchers: []string{"m"}, + values: []string{"1"}, + }, + } + + parser, err := NewParser(matchers) + require.NoError(t, err) + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + parse, err := parser.Parse(test.rule) + if test.expectParseErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + treeBuilder, ok := parse.(TreeBuilder) + require.True(t, ok) + + tree := treeBuilder() + checkEquivalence(t, &test.tree, tree) + + assert.Equal(t, test.values, tree.ParseMatchers(test.matchers)) + }) + } +} + +func checkEquivalence(t *testing.T, expected *testTree, actual *Tree) { + t.Helper() + + if actual == nil { + return + } + + if actual.RuleLeft != nil { + checkEquivalence(t, expected.RuleLeft, actual.RuleLeft) + } + + if actual.RuleRight != nil { + checkEquivalence(t, expected.RuleRight, actual.RuleRight) + } + + assert.Equal(t, expected.Matcher, actual.Matcher) + assert.Equal(t, expected.Not, actual.Not) + assert.Equal(t, expected.Value, actual.Value) + + t.Logf("%+v -> %v", actual, CheckRule(actual)) + if expected.CheckErr { + assert.Error(t, CheckRule(actual)) + } else { + assert.NoError(t, CheckRule(actual)) + } +}