From 35c04638292dd68e260a5b5aa0d5a9548931d5a7 Mon Sep 17 00:00:00 2001 From: yusing Date: Wed, 8 Jan 2025 07:18:09 +0800 Subject: [PATCH] naive implementation of caddy like route rules, dependencies upgrade --- .trunk/trunk.yaml | 6 +- go.mod | 9 +- go.sum | 18 +- internal/net/http/serve_mux.go | 10 + internal/route/http.go | 7 + internal/route/types/raw_entry.go | 1 + internal/route/types/rules.go | 367 +++++++++++++++++++++++++++++ internal/route/types/rules_test.go | 146 ++++++++++++ internal/utils/strutils/glob.go | 28 +++ 9 files changed, 577 insertions(+), 15 deletions(-) create mode 100644 internal/route/types/rules.go create mode 100644 internal/route/types/rules_test.go create mode 100644 internal/utils/strutils/glob.go diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index d473f53..2719443 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -22,8 +22,8 @@ lint: - yamllint enabled: - hadolint@2.12.1-beta - - actionlint@1.7.5 - - checkov@3.2.346 + - actionlint@1.7.6 + - checkov@3.2.347 - git-diff-check - gofmt@1.20.4 - golangci-lint@1.62.2 @@ -32,7 +32,7 @@ lint: - prettier@3.4.2 - shellcheck@0.10.0 - shfmt@3.6.0 - - trufflehog@3.88.0 + - trufflehog@3.88.1 actions: disabled: - trunk-announce diff --git a/go.mod b/go.mod index 42bf0eb..0094652 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,13 @@ require ( github.com/fsnotify/fsnotify v1.8.0 github.com/go-acme/lego/v4 v4.21.0 github.com/go-playground/validator/v10 v10.23.0 + github.com/gobwas/glob v0.2.3 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gotify/server/v2 v2.6.1 github.com/prometheus/client_golang v1.20.5 github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/rs/zerolog v1.33.0 - golang.org/x/net v0.33.0 + golang.org/x/net v0.34.0 golang.org/x/text v0.21.0 golang.org/x/time v0.9.0 gopkg.in/yaml.v3 v3.0.1 @@ -64,13 +65,13 @@ require ( go.opentelemetry.io/otel/metric v1.33.0 // indirect go.opentelemetry.io/otel/sdk v1.30.0 // indirect go.opentelemetry.io/otel/trace v1.33.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.32.0 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/tools v0.28.0 // indirect - google.golang.org/protobuf v1.36.1 // indirect + golang.org/x/tools v0.29.0 // indirect + google.golang.org/protobuf v1.36.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/go.sum index f361f2f..9ce4691 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -157,8 +159,8 @@ go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= @@ -167,8 +169,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -195,8 +197,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -208,8 +210,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= +google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/net/http/serve_mux.go b/internal/net/http/serve_mux.go index 863c8d9..0e487b6 100644 --- a/internal/net/http/serve_mux.go +++ b/internal/net/http/serve_mux.go @@ -10,6 +10,16 @@ func NewServeMux() ServeMux { return ServeMux{http.NewServeMux()} } +func (mux ServeMux) Handle(pattern string, handler http.Handler) (err error) { + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + mux.ServeMux.Handle(pattern, handler) + return +} + func (mux ServeMux) HandleFunc(pattern string, handler http.HandlerFunc) (err error) { defer func() { if r := recover(); r != nil { diff --git a/internal/route/http.go b/internal/route/http.go index c67324e..eb60cf2 100755 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -121,6 +121,9 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error { case len(pathPatterns) == 1 && pathPatterns[0] == "/": r.handler = r.rp default: + logger.Warn(). + Str("route", r.TargetName()). + Msg("`path_patterns` is deprecated. Use `rules` instead.") mux := gphttp.NewServeMux() patErrs := E.NewBuilder("invalid path pattern(s)") for _, p := range pathPatterns { @@ -134,6 +137,10 @@ func (r *HTTPRoute) Start(parent task.Parent) E.Error { } } + if len(r.Raw.Rules) > 0 { + r.handler = r.Raw.Rules.BuildHandler(r.rp) + } + if r.HealthMon != nil { if err := r.HealthMon.Start(r.task); err != nil { E.LogWarn("health monitor error", err, &r.l) diff --git a/internal/route/types/raw_entry.go b/internal/route/types/raw_entry.go index a4d4661..92a77b0 100644 --- a/internal/route/types/raw_entry.go +++ b/internal/route/types/raw_entry.go @@ -30,6 +30,7 @@ type ( Port string `json:"port,omitempty"` NoTLSVerify bool `json:"no_tls_verify,omitempty"` PathPatterns []string `json:"path_patterns,omitempty"` + Rules Rules `json:"rules,omitempty"` HealthCheck *health.HealthCheckConfig `json:"healthcheck,omitempty"` LoadBalance *loadbalance.Config `json:"load_balance,omitempty"` Middlewares map[string]docker.LabelMap `json:"middlewares,omitempty"` diff --git a/internal/route/types/rules.go b/internal/route/types/rules.go new file mode 100644 index 0000000..be6f7f7 --- /dev/null +++ b/internal/route/types/rules.go @@ -0,0 +1,367 @@ +package types + +import ( + "net/http" + "path" + "strconv" + "strings" + + E "github.com/yusing/go-proxy/internal/error" + gphttp "github.com/yusing/go-proxy/internal/net/http" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type ( + Rules []Rule + Rule struct { + Name string `json:"name" validate:"required,unique"` + On RuleOn `json:"on"` + Do Command `json:"do"` + } + RuleOn struct { + raw string + checkers []CheckFulfill + } + Command struct { + raw string + CommandExecutor + } + CheckFulfill func(r *http.Request) bool + RequestObjectRetriever struct { + expectedArgs int + retrieve func(r *http.Request, args []string) string + equal func(v, want string) bool + } + CommandExecutor struct { + http.HandlerFunc + proceed bool + } + CommandBuilder struct { + expectedArgs int + build func(args []string) CommandExecutor + } +) + +/* +proxy.app1.rules: | + - name: default + do: | + rewrite / /index.html + serve /var/www/goaccess + - name: ws + on: | + header Connection upgrade + header Upgrade websocket + do: proxy $upstream_url +*/ + +var ( + ErrUnterminatedQuotes = E.New("unterminated quotes") + ErrUnsupportedEscapeChar = E.New("unsupported escape char") + ErrUnknownDirective = E.New("unknown directive") + ErrInvalidArguments = E.New("invalid arguments") + ErrInvalidCriteria = E.New("invalid criteria") + ErrInvalidCriteriaTarget = E.New("invalid criteria target") +) + +var retrievers = map[string]RequestObjectRetriever{ + "header": {1, func(r *http.Request, args []string) string { + return r.Header.Get(args[0]) + }, nil}, + "query": {1, func(r *http.Request, args []string) string { + return r.URL.Query().Get(args[0]) + }, nil}, + "method": {0, func(r *http.Request, _ []string) string { + return r.Method + }, nil}, + "path": {0, func(r *http.Request, _ []string) string { + return r.URL.Path + }, func(v, want string) bool { + return strutils.GlobMatch(want, v) + }}, + "remote": {0, func(r *http.Request, _ []string) string { + return r.RemoteAddr + }, nil}, +} + +var commands = map[string]CommandBuilder{ + "rewrite": {2, func(args []string) CommandExecutor { + orig, repl := args[0], args[1] + return CommandExecutor{ + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = strings.Replace(r.URL.Path, orig, repl, 1) + r.URL.RawPath = r.URL.EscapedPath() + r.RequestURI = r.URL.String() + }, + proceed: true, + } + }}, + "serve": {1, func(args []string) CommandExecutor { + return CommandExecutor{ + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, path.Join(args[0], path.Clean(r.URL.Path))) + }, + proceed: false, + } + }}, + "redirect": {1, func(args []string) CommandExecutor { + target := args[0] + return CommandExecutor{ + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target, http.StatusTemporaryRedirect) + }, + proceed: false, + } + }}, + "error": {2, func(args []string) CommandExecutor { + codeStr, text := args[0], args[1] + code, err := strconv.Atoi(codeStr) + if err != nil { + code = http.StatusNotFound + } + return CommandExecutor{ + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + http.Error(w, text, code) + }, + proceed: false, + } + }}, + "proxy": {1, func(args []string) CommandExecutor { + target := args[0] + return CommandExecutor{ + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + r.URL.Scheme = "http" + r.URL.Host = target + r.URL.RawPath = r.URL.EscapedPath() + r.RequestURI = r.URL.String() + }, + proceed: true, + } + }}, +} + +var escapedChars = map[rune]rune{ + 'n': '\n', + 't': '\t', + 'r': '\r', + '\'': '\'', + '"': '"', + ' ': ' ', +} + +// BuildHandler returns a http.HandlerFunc that implements the rules. +// +// Bypass rules are executed first +// if a bypass rule matches, +// the request is passed to the upstream and no more rules are executed. +// +// Other rules are executed later +// if no rule matches, the default rule is executed +// if no rule matches and default rule is not set, +// the request is passed to the upstream. +func (rules Rules) BuildHandler(up *gphttp.ReverseProxy) http.HandlerFunc { + // move bypass rules to the front. + bypassRules := make(Rules, 0, len(rules)) + otherRules := make(Rules, 0, len(rules)) + + var defaultRule Rule + + for _, rule := range rules { + switch { + case rule.Do.isBypass(): + bypassRules = append(bypassRules, rule) + case rule.Name == "default": + defaultRule = rule + default: + otherRules = append(otherRules, rule) + } + } + + // free allocated empty slices + // before passing them to the handler. + if len(bypassRules) == 0 { + bypassRules = []Rule{} + } + if len(otherRules) == 0 { + otherRules = []Rule{defaultRule} + } + + return func(w http.ResponseWriter, r *http.Request) { + hasMatch := false + for _, rule := range bypassRules { + if rule.On.MatchAll(r) { + up.ServeHTTP(w, r) + return + } + } + for _, rule := range otherRules { + if rule.On.MatchAll(r) { + hasMatch = true + rule.Do.HandlerFunc(w, r) + if !rule.Do.proceed { + return + } + } + } + if hasMatch || defaultRule.Do.isBypass() { + up.ServeHTTP(w, r) + return + } + + defaultRule.Do.HandlerFunc(w, r) + if !defaultRule.Do.proceed { + return + } + } +} + +// parse line to subject and args +// with support for quotes and escaped chars, e.g. +// +// error 403 "Forbidden 'foo' 'bar'" +// error 403 Forbidden\ \"foo\"\ \"bar\". +func parse(v string) (subject string, args []string, err E.Error) { + v = strings.TrimSpace(v) + var buf strings.Builder + escaped := false + quotes := make([]rune, 0, 4) + flush := func() { + if subject == "" { + subject = buf.String() + } else { + args = append(args, buf.String()) + } + buf.Reset() + } + for _, r := range v { + if escaped { + if ch, ok := escapedChars[r]; ok { + buf.WriteRune(ch) + } else { + err = ErrUnsupportedEscapeChar.Subjectf("\\%c", r) + return + } + escaped = false + continue + } + switch r { + case '\\': + escaped = true + continue + case '"', '\'': + switch { + case len(quotes) > 0 && quotes[len(quotes)-1] == r: + quotes = quotes[:len(quotes)-1] + if len(quotes) == 0 { + flush() + } else { + buf.WriteRune(r) + } + case len(quotes) == 0: + quotes = append(quotes, r) + default: + buf.WriteRune(r) + } + case ' ': + flush() + default: + buf.WriteRune(r) + } + } + + if len(quotes) > 0 { + err = ErrUnterminatedQuotes + } else { + flush() + } + return +} + +func (on *RuleOn) Parse(v string) E.Error { + lines := strutils.SplitLine(v) + on.checkers = make([]CheckFulfill, 0, len(lines)) + on.raw = v + + errs := E.NewBuilder("rule.on syntax errors") + for i, line := range lines { + subject, args, err := parse(line) + if err != nil { + errs.Add(err.Subjectf("line %d", i+1)) + continue + } + retriever, ok := retrievers[subject] + if !ok { + errs.Add(ErrInvalidCriteriaTarget.Subject(subject).Subjectf("line %d", i+1)) + continue + } + nArgs := retriever.expectedArgs + if len(args) != nArgs+1 { + errs.Add(ErrInvalidArguments.Subject(subject).Subjectf("line %d", i+1)) + continue + } + equal := retriever.equal + if equal == nil { + equal = func(a, b string) bool { + return a == b + } + } + on.checkers = append(on.checkers, func(r *http.Request) bool { + return equal(retriever.retrieve(r, args[:nArgs]), args[nArgs]) + }) + } + return errs.Error() +} + +func (on *RuleOn) MatchAll(r *http.Request) bool { + for _, match := range on.checkers { + if !match(r) { + return false + } + } + return true +} + +func (cmd *Command) Parse(v string) E.Error { + cmd.raw = v + directive, args, err := parse(v) + if err != nil { + return err + } + + if directive == "bypass" { + if len(args) != 0 { + return ErrInvalidArguments.Subject(directive) + } + return nil + } + + builder, ok := commands[directive] + if !ok { + return ErrUnknownDirective.Subject(directive) + } + if len(args) != builder.expectedArgs { + return ErrInvalidArguments.Subject(directive) + } + cmd.CommandExecutor = builder.build(args) + return nil +} + +func (cmd *Command) isBypass() bool { + return cmd.HandlerFunc == nil +} + +func (on *RuleOn) String() string { + return on.raw +} + +func (on *RuleOn) MarshalJSON() ([]byte, error) { + return []byte("\"" + on.String() + "\""), nil +} + +func (cmd *Command) String() string { + return cmd.raw +} + +func (cmd *Command) MarshalJSON() ([]byte, error) { + return []byte("\"" + cmd.String() + "\""), nil +} diff --git a/internal/route/types/rules_test.go b/internal/route/types/rules_test.go new file mode 100644 index 0000000..9f8a414 --- /dev/null +++ b/internal/route/types/rules_test.go @@ -0,0 +1,146 @@ +package types + +import ( + "testing" + + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestParseSubjectArgs(t *testing.T) { + t.Run("without quotes", func(t *testing.T) { + subject, args, err := parse("rewrite / /foo/bar") + ExpectNoError(t, err) + ExpectEqual(t, subject, "rewrite") + ExpectDeepEqual(t, args, []string{"/", "/foo/bar"}) + }) + t.Run("with quotes", func(t *testing.T) { + subject, args, err := parse(`error 403 "Forbidden 'foo' 'bar'."`) + ExpectNoError(t, err) + ExpectEqual(t, subject, "error") + ExpectDeepEqual(t, args, []string{"403", "Forbidden 'foo' 'bar'."}) + }) + t.Run("with escaped", func(t *testing.T) { + subject, args, err := parse(`error 403 Forbidden\ \"foo\"\ \"bar\".`) + ExpectNoError(t, err) + ExpectEqual(t, subject, "error") + ExpectDeepEqual(t, args, []string{"403", "Forbidden \"foo\" \"bar\"."}) + }) +} + +func TestParseCommands(t *testing.T) { + tests := []struct { + name string + input string + wantErr error + }{ + // bypass tests + { + name: "bypass_valid", + input: "bypass", + wantErr: nil, + }, + { + name: "bypass_invalid_with_args", + input: "bypass /", + wantErr: ErrInvalidArguments, + }, + // rewrite tests + { + name: "rewrite_valid", + input: "rewrite / /foo/bar", + wantErr: nil, + }, + { + name: "rewrite_missing_target", + input: "rewrite /", + wantErr: ErrInvalidArguments, + }, + { + name: "rewrite_too_many_args", + input: "rewrite / / /", + wantErr: ErrInvalidArguments, + }, + // serve tests + { + name: "serve_valid", + input: "serve /var/www", + wantErr: nil, + }, + { + name: "serve_missing_path", + input: "serve ", + wantErr: ErrInvalidArguments, + }, + { + name: "serve_too_many_args", + input: "serve / / /", + wantErr: ErrInvalidArguments, + }, + // redirect tests + { + name: "redirect_valid", + input: "redirect /", + wantErr: nil, + }, + { + name: "redirect_too_many_args", + input: "redirect / /", + wantErr: ErrInvalidArguments, + }, + // error directive tests + { + name: "error_valid", + input: "error 404 Not\\ Found", + wantErr: nil, + }, + { + name: "error_missing_status_code", + input: "error Not\\ Found", + wantErr: ErrInvalidArguments, + }, + { + name: "error_too_many_args", + input: "error 404 Not\\ Found extra", + wantErr: ErrInvalidArguments, + }, + { + name: "error_unescaped_space", + input: "error 404 Not Found", + wantErr: ErrInvalidArguments, + }, + // proxy directive tests + { + name: "proxy_valid", + input: "proxy localhost:8080", + wantErr: nil, + }, + { + name: "proxy_missing_target", + input: "proxy", + wantErr: ErrInvalidArguments, + }, + { + name: "proxy_too_many_args", + input: "proxy localhost:8080 extra", + wantErr: ErrInvalidArguments, + }, + // unknown directive test + { + name: "unknown_directive", + input: "unknown /", + wantErr: ErrUnknownDirective, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := Command{} + err := cmd.Parse(tt.input) + if tt.wantErr != nil { + ExpectError(t, tt.wantErr, err) + } else { + ExpectNoError(t, err) + } + }) + } +} diff --git a/internal/utils/strutils/glob.go b/internal/utils/strutils/glob.go new file mode 100644 index 0000000..6b8047d --- /dev/null +++ b/internal/utils/strutils/glob.go @@ -0,0 +1,28 @@ +package strutils + +import ( + "sync" + + "github.com/gobwas/glob" +) + +var ( + globPatterns = make(map[string]glob.Glob) + globPatternsMu sync.Mutex +) + +func GlobMatch(pattern string, s string) bool { + if glob, ok := globPatterns[pattern]; ok { + return glob.Match(s) + } + + globPatternsMu.Lock() + defer globPatternsMu.Unlock() + + glob, err := glob.Compile(pattern) + if err != nil { + return false + } + globPatterns[pattern] = glob + return glob.Match(s) +}