├── flagr ├── flagr-config.png ├── local-vs-remote.png ├── go.mod ├── converter.go ├── evaluator │ ├── local.go │ └── remote.go ├── caddyfile.go ├── README.md └── flagr.go ├── layer4 ├── go.mod ├── README.md └── caddyfile.go ├── ratelimit ├── go.mod ├── zone.go ├── zone_test.go ├── caddyfile.go ├── README.md ├── ratelimit_test.go └── ratelimit.go ├── README.md ├── requestbodyvar ├── go.mod ├── README.md ├── querier_test.go ├── querier.go └── requestbodyvar.go ├── dynamichandler ├── yaegisymbols │ ├── symbols.go │ └── github_com-RussellLuo-caddy-ext-dynamichandler-caddymiddleware.go ├── caddymiddleware │ └── caddymiddleware.go ├── dynamichandler_test.go ├── plugins │ └── visitorip │ │ └── visitorip.go ├── caddyfile.go ├── README.md ├── dynamichandler.go └── go.mod └── LICENSE /flagr/flagr-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RussellLuo/caddy-ext/HEAD/flagr/flagr-config.png -------------------------------------------------------------------------------- /flagr/local-vs-remote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RussellLuo/caddy-ext/HEAD/flagr/local-vs-remote.png -------------------------------------------------------------------------------- /layer4/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RussellLuo/caddy-ext/layer4 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/caddyserver/caddy/v2 v2.4.6 7 | github.com/mholt/caddy-l4 v0.0.0-20220125094439-07bd718906ce 8 | ) 9 | -------------------------------------------------------------------------------- /ratelimit/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RussellLuo/caddy-ext/ratelimit 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b 7 | github.com/caddyserver/caddy/v2 v2.4.5 8 | github.com/hashicorp/golang-lru v0.5.1 9 | go.uber.org/zap v1.19.0 10 | ) 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # caddy-ext 2 | 3 | Various Caddy v2 extensions (a.k.a. modules). 4 | 5 | 6 | ## Extensions 7 | 8 | - [dynamichandler](dynamichandler) 9 | - [flagr](flagr) 10 | - [layer4](layer4) 11 | - [ratelimit](ratelimit) 12 | - [requestbodyvar](requestbodyvar) 13 | 14 | 15 | ## License 16 | 17 | [MIT](LICENSE) 18 | -------------------------------------------------------------------------------- /requestbodyvar/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RussellLuo/caddy-ext/requestbodyvar 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/basgys/goxml2json v1.1.0 7 | github.com/bitly/go-simplejson v0.5.0 // indirect 8 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect 9 | github.com/caddyserver/caddy/v2 v2.4.5 10 | github.com/tidwall/gjson v1.6.7 11 | go.uber.org/zap v1.19.0 12 | ) 13 | -------------------------------------------------------------------------------- /dynamichandler/yaegisymbols/symbols.go: -------------------------------------------------------------------------------- 1 | package yaegisymbols 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | // Symbols stores the map of caddymiddleware package symbols. 8 | var Symbols = map[string]map[string]reflect.Value{} 9 | 10 | type Middleware = _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware 11 | 12 | //go:generate yaegi extract github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware 13 | -------------------------------------------------------------------------------- /dynamichandler/caddymiddleware/caddymiddleware.go: -------------------------------------------------------------------------------- 1 | package caddymiddleware 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type Handler interface { 8 | ServeHTTP(http.ResponseWriter, *http.Request) error 9 | } 10 | 11 | type HandlerFunc func(http.ResponseWriter, *http.Request) error 12 | 13 | func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error { 14 | return f(w, r) 15 | } 16 | 17 | type Middleware interface { 18 | Provision() error 19 | Validate() error 20 | Cleanup() error 21 | ServeHTTP(http.ResponseWriter, *http.Request, Handler) error 22 | } 23 | -------------------------------------------------------------------------------- /flagr/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RussellLuo/caddy-ext/flagr 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/caddyserver/caddy/v2 v2.3.0 7 | github.com/checkr/flagr v0.0.0-20210503185549-d4b7b279061d 8 | github.com/checkr/goflagr v0.0.0-20210326170947-e97f63ad2c23 9 | github.com/golang/mock v1.6.0 // indirect 10 | github.com/onsi/ginkgo v1.16.4 // indirect 11 | github.com/onsi/gomega v1.13.0 // indirect 12 | go.uber.org/zap v1.17.0 13 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect 14 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect 15 | golang.org/x/oauth2 v0.0.0-20210615190721-d04028783cf1 // indirect 16 | golang.org/x/tools v0.1.3 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /requestbodyvar/README.md: -------------------------------------------------------------------------------- 1 | # requestbodyvar 2 | 3 | A Caddy v2 extension to add support for the `{http.request.body.*}` [placeholder][1] (variable). 4 | 5 | 6 | ## Installation 7 | 8 | ```bash 9 | $ xcaddy build --with github.com/RussellLuo/caddy-ext/requestbodyvar 10 | ``` 11 | 12 | ## Example 13 | 14 | With the following Caddyfile: 15 | 16 | ``` 17 | localhost:8080 { 18 | route / { 19 | request_body_var 20 | 21 | respond {http.request.body.name} 22 | } 23 | } 24 | ``` 25 | 26 | You can get the responses as below: 27 | 28 | ```bash 29 | $ curl -XPOST https://localhost:8080 -d '{"name":"caddy"}' 30 | caddy 31 | $ curl -XPOST https://localhost:8080 -d '{"name":"wow"}' 32 | wow 33 | ``` 34 | 35 | 36 | [1]: https://caddyserver.com/docs/conventions#placeholders 37 | 38 | -------------------------------------------------------------------------------- /requestbodyvar/querier_test.go: -------------------------------------------------------------------------------- 1 | package requestbodyvar 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestJSON_Query(t *testing.T) { 9 | j := JSON{buf: bytes.NewBufferString(`{"name":{"first":"Janet","last":"Prichard"},"age":47}`)} 10 | want := "Prichard" 11 | result := j.Query("name.last") 12 | if result != want { 13 | t.Fatalf("Result: got (%#v), want (%#v)", result, want) 14 | } 15 | } 16 | 17 | func TestXML_Query(t *testing.T) { 18 | x := XML{buf: bytes.NewBufferString(` 19 | 20 | Janet 21 | Prichard 22 | 23 | 47`)} 24 | want := "Prichard" 25 | result := x.Query("name.last") 26 | if result != want { 27 | t.Fatalf("Result: got (%#v), want (%#v)", result, want) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /dynamichandler/dynamichandler_test.go: -------------------------------------------------------------------------------- 1 | package dynamichandler 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware" 11 | ) 12 | 13 | func TestDynamicHandler_eval(t *testing.T) { 14 | dh := &DynamicHandler{ 15 | Name: "visitorip", 16 | Root: "plugins", 17 | Config: `{"output": "stdout"}`, 18 | logger: zap.NewNop(), 19 | } 20 | if err := dh.provision(); err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | w := httptest.NewRecorder() 25 | r := httptest.NewRequest(http.MethodGet, "/", nil) 26 | next := caddymiddleware.HandlerFunc(func(http.ResponseWriter, *http.Request) error { return nil }) 27 | if err := dh.middleware.ServeHTTP(w, r, next); err != nil { 28 | t.Fatal(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /flagr/converter.go: -------------------------------------------------------------------------------- 1 | package flagr 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type Converter func(string) (interface{}, error) 9 | 10 | // A set of converters for string values. 11 | var converters = map[string]Converter{ 12 | "int": toInt, 13 | } 14 | 15 | func AddConverter(n string, c Converter) error { 16 | if _, ok := converters[n]; ok { 17 | return fmt.Errorf("converter name %q is reserved", n) 18 | } 19 | converters[n] = c 20 | return nil 21 | } 22 | 23 | func GetConverter(n string) (Converter, error) { 24 | c, ok := converters[n] 25 | if !ok { 26 | return nil, fmt.Errorf("converter name %q is not found", n) 27 | } 28 | return c, nil 29 | } 30 | 31 | func toInt(v string) (interface{}, error) { 32 | i, err := strconv.Atoi(v) 33 | if err != nil { 34 | return 0, err 35 | } 36 | return i, nil 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luo Peng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dynamichandler/plugins/visitorip/visitorip.go: -------------------------------------------------------------------------------- 1 | package visitorip 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware" 10 | ) 11 | 12 | // Middleware implements an HTTP handler that writes the 13 | // visitor's IP address to a file or stream. 14 | type Middleware struct { 15 | // The file or stream to write to. Can be "stdout" 16 | // or "stderr". 17 | Output string `json:"output,omitempty"` 18 | 19 | w io.Writer 20 | } 21 | 22 | func New() caddymiddleware.Middleware { 23 | return &Middleware{} 24 | } 25 | 26 | func (m *Middleware) Provision() error { 27 | switch m.Output { 28 | case "stdout": 29 | m.w = os.Stdout 30 | case "stderr": 31 | m.w = os.Stderr 32 | default: 33 | return fmt.Errorf("an output stream is required") 34 | } 35 | return nil 36 | } 37 | 38 | func (m *Middleware) Validate() error { 39 | if m.w == nil { 40 | return fmt.Errorf("no writer") 41 | } 42 | return nil 43 | } 44 | 45 | func (m *Middleware) Cleanup() error { return nil } 46 | 47 | func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddymiddleware.Handler) error { 48 | _, _ = m.w.Write([]byte(r.RemoteAddr + "\n")) 49 | return next.ServeHTTP(w, r) 50 | } 51 | -------------------------------------------------------------------------------- /requestbodyvar/querier.go: -------------------------------------------------------------------------------- 1 | package requestbodyvar 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "mime" 7 | "strings" 8 | 9 | "github.com/basgys/goxml2json" 10 | "github.com/tidwall/gjson" 11 | ) 12 | 13 | type Querier interface { 14 | Query(string) string 15 | } 16 | 17 | type JSON struct { 18 | buf *bytes.Buffer 19 | } 20 | 21 | func (j JSON) Query(key string) string { 22 | return getJSONField(j.buf, key) 23 | } 24 | 25 | type XML struct { 26 | buf *bytes.Buffer 27 | } 28 | 29 | func (x XML) Query(key string) string { 30 | json, err := xml2json.Convert(x.buf) 31 | if err != nil { 32 | return "" 33 | } 34 | return getJSONField(json, key) 35 | } 36 | 37 | func newQuerier(buf *bytes.Buffer, contentType string) (Querier, error) { 38 | mediaType := "application/json" 39 | if contentType != "" { 40 | var err error 41 | mediaType, _, err = mime.ParseMediaType(contentType) 42 | if err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | switch { 48 | case mediaType == "application/json": 49 | return JSON{buf: buf}, nil 50 | case strings.HasSuffix(mediaType, "/xml"): 51 | // application/xml 52 | // text/xml 53 | return XML{buf: buf}, nil 54 | default: 55 | return nil, fmt.Errorf("unsupported Media Type: %q", mediaType) 56 | } 57 | } 58 | 59 | // getJSONField gets the value of the given field from the JSON body, 60 | // which is buffered in buf. 61 | func getJSONField(buf *bytes.Buffer, key string) string { 62 | if buf == nil { 63 | return "" 64 | } 65 | value := gjson.GetBytes(buf.Bytes(), key) 66 | return value.String() 67 | } 68 | -------------------------------------------------------------------------------- /flagr/evaluator/local.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/checkr/flagr/pkg/config" 9 | "github.com/checkr/flagr/pkg/handler" 10 | "github.com/checkr/flagr/swagger_gen/models" 11 | "github.com/checkr/flagr/swagger_gen/restapi/operations/evaluation" 12 | ) 13 | 14 | var ( 15 | onceLocal sync.Once 16 | singletonLocal *Local 17 | ) 18 | 19 | type Local struct { 20 | eval handler.Eval 21 | } 22 | 23 | func NewLocal(interval time.Duration, url string) (*Local, error) { 24 | var err error 25 | onceLocal.Do(func() { 26 | // Change the global configuration of Flagr once. 27 | config.Config.EvalLoggingEnabled = false 28 | config.Config.EvalCacheRefreshInterval = interval 29 | config.Config.EvalOnlyMode = true 30 | config.Config.DBDriver = "json_http" 31 | // The URL for exporting JSON format of the eval cache dump, 32 | // see https://checkr.github.io/flagr/api_docs/#operation/getExportEvalCacheJSON 33 | config.Config.DBConnectionStr = url + "/export/eval_cache/json" 34 | 35 | // Start the singleton eval cache once. 36 | defer func() { 37 | // EvalCache.Start() may panic if it fails. 38 | if r := recover(); r != nil { 39 | if e, ok := r.(error); ok { 40 | err = e 41 | } 42 | } 43 | }() 44 | handler.GetEvalCache().Start() 45 | 46 | singletonLocal = &Local{ 47 | eval: handler.NewEval(), 48 | } 49 | }) 50 | return singletonLocal, err 51 | } 52 | 53 | func (l *Local) PostEvaluationBatch(ctx context.Context, req *models.EvaluationBatchRequest) (*models.EvaluationBatchResponse, error) { 54 | resp := l.eval.PostEvaluationBatch(evaluation.PostEvaluationBatchParams{Body: req}) 55 | ok := resp.(*evaluation.PostEvaluationBatchOK) 56 | return ok.Payload, nil 57 | } 58 | -------------------------------------------------------------------------------- /ratelimit/zone.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "time" 5 | "fmt" 6 | 7 | sw "github.com/RussellLuo/slidingwindow" 8 | "github.com/hashicorp/golang-lru" 9 | ) 10 | 11 | type Zone struct { 12 | limiters *lru.Cache 13 | 14 | rateSize time.Duration 15 | rateLimit int64 16 | } 17 | 18 | func NewZone(size int, rateSize time.Duration, rateLimit int64) (*Zone, error) { 19 | cache, err := lru.New(size) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return &Zone{ 24 | limiters: cache, 25 | rateSize: rateSize, 26 | rateLimit: rateLimit, 27 | }, nil 28 | } 29 | 30 | // Purge is used to completely clear the zone. 31 | func (z *Zone) Purge() { 32 | z.limiters.Purge() 33 | } 34 | 35 | func (z *Zone) Allow(key string) bool { 36 | lim, _, _ := z.getLimiter(key) 37 | return lim.Allow() 38 | } 39 | 40 | func (z *Zone) RateLimitPolicyHeader() string { 41 | return fmt.Sprintf("%d; w=%d", z.rateLimit, int(z.rateSize.Seconds())) 42 | } 43 | 44 | func (z *Zone) getLimiter(key string) (lim *sw.Limiter, ok, evict bool) { 45 | // If there is already a limiter for key, just return it. 46 | elem, ok := z.limiters.Peek(key) 47 | if ok { 48 | return elem.(*sw.Limiter), true, false 49 | } 50 | 51 | lim, _ = sw.NewLimiter(z.rateSize, z.rateLimit, func() (sw.Window, sw.StopFunc) { 52 | // NewLocalWindow returns an empty stop function, so it's 53 | // unnecessary to call it later. 54 | return sw.NewLocalWindow() 55 | }) 56 | // Try to add lim as the limiter for key. 57 | ok, evict = z.limiters.ContainsOrAdd(key, lim) 58 | 59 | if ok { 60 | // The limiter for key has been added by someone else just now. 61 | // We should use the limiter rather than our lim. 62 | elem, _ = z.limiters.Peek(key) 63 | lim = elem.(*sw.Limiter) 64 | } 65 | 66 | return 67 | } 68 | -------------------------------------------------------------------------------- /ratelimit/zone_test.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | sw "github.com/RussellLuo/slidingwindow" 8 | ) 9 | 10 | func TestZone_getLimiter(t *testing.T) { 11 | zone, _ := NewZone(2, time.Second, 10) 12 | 13 | cases := []struct { 14 | key string 15 | ok bool 16 | evict bool 17 | }{ 18 | {"key1", false, false}, 19 | {"key1", true, false}, 20 | {"key2", false, false}, 21 | {"key3", false, true}, 22 | } 23 | 24 | for _, c := range cases { 25 | lim, ok, evict := zone.getLimiter(c.key) 26 | if lim == nil { 27 | t.Fatalf("Limiter is nil") 28 | } 29 | 30 | if ok != c.ok { 31 | t.Fatalf("Found: got (%#v), want (%#v)", ok, c.ok) 32 | } 33 | 34 | if evict != c.evict { 35 | t.Fatalf("Evict: got (%#v), want (%#v)", evict, c.evict) 36 | } 37 | } 38 | } 39 | 40 | func TestZone_getLimiterConcurrently(t *testing.T) { 41 | test := func(n int) { 42 | zone, _ := NewZone(1, time.Second, 10) 43 | key := "key1" 44 | 45 | limC := make(chan *sw.Limiter, n) 46 | startC := make(chan struct{}) 47 | for i := 0; i < n; i++ { 48 | go func() { 49 | <-startC 50 | lim, _, _ := zone.getLimiter(key) 51 | limC <- lim 52 | }() 53 | } 54 | 55 | // Send a START signal to all the goroutines. 56 | close(startC) 57 | 58 | var gotLims []*sw.Limiter 59 | for i := 0; i < n; i++ { 60 | // Collect all the result limiters. 61 | gotLims = append(gotLims, <-limC) 62 | } 63 | 64 | elem, ok := zone.limiters.Peek(key) 65 | if !ok { 66 | t.Fatalf("Found no limiter") 67 | } 68 | wantLim := elem.(*sw.Limiter) 69 | 70 | for _, lim := range gotLims { 71 | if lim != wantLim { 72 | t.Fatalf("Limiter: got (%#v), want (%#v)", lim, wantLim) 73 | } 74 | } 75 | } 76 | 77 | cases := []struct { 78 | name string 79 | degree int 80 | }{ 81 | {"concurrency-degree-8", 8}, 82 | {"concurrency-degree-32", 32}, 83 | {"concurrency-degree-64", 64}, 84 | } 85 | 86 | for _, c := range cases { 87 | t.Run(c.name, func(t *testing.T) { 88 | test(c.degree) 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /ratelimit/caddyfile.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 7 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 8 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 9 | ) 10 | 11 | func init() { 12 | httpcaddyfile.RegisterHandlerDirective("rate_limit", parseCaddyfile) 13 | } 14 | 15 | // parseCaddyfile sets up a handler for rate-limiting from Caddyfile tokens. Syntax: 16 | // 17 | // rate_limit [] [ []] 18 | // 19 | // Parameters: 20 | // - : The variable used to differentiate one client from another. 21 | // - : The request rate limit (per key value) specified in requests per second (r/s) or requests per minute (r/m). 22 | // - : The size (i.e. the number of key values) of the LRU zone that keeps states of these key values. Defaults to 10,000. 23 | // - : The HTTP status code of the response when a client exceeds the rate. Defaults to 429 (Too Many Requests). 24 | func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 25 | rl := new(RateLimit) 26 | if err := rl.UnmarshalCaddyfile(h.Dispenser); err != nil { 27 | return nil, err 28 | } 29 | return rl, nil 30 | } 31 | 32 | func (rl *RateLimit) UnmarshalCaddyfile(d *caddyfile.Dispenser) (err error) { 33 | if d.Next() { 34 | args := d.RemainingArgs() 35 | switch len(args) { 36 | case 4: 37 | rl.RejectStatusCode, err = strconv.Atoi(args[3]) 38 | if err != nil { 39 | return d.Errf("reject_status must be an integer; invalid: %v", err) 40 | } 41 | fallthrough 42 | case 3: 43 | size, err := strconv.Atoi(args[2]) 44 | if err != nil { 45 | return d.Errf("zone_size must be an integer; invalid: %v", err) 46 | } 47 | rl.ZoneSize = size 48 | fallthrough 49 | case 2: 50 | rl.Rate = args[1] 51 | rl.Key = args[0] 52 | default: 53 | return d.ArgErr() 54 | } 55 | } 56 | return nil 57 | } 58 | 59 | // Interface guards 60 | var ( 61 | _ caddyfile.Unmarshaler = (*RateLimit)(nil) 62 | ) 63 | -------------------------------------------------------------------------------- /dynamichandler/caddyfile.go: -------------------------------------------------------------------------------- 1 | package dynamichandler 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 8 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 9 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 10 | ) 11 | 12 | func init() { 13 | httpcaddyfile.RegisterHandlerDirective("dynamic_handler", parseCaddyfile) 14 | } 15 | 16 | // parseCaddyfile sets up a handler from Caddyfile tokens. 17 | func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 18 | dh := new(DynamicHandler) 19 | if err := dh.UnmarshalCaddyfile(h.Dispenser); err != nil { 20 | return nil, err 21 | } 22 | return dh, nil 23 | } 24 | 25 | // UnmarshalCaddyfile implements caddyfile.Unmarshaler. Syntax: 26 | // 27 | // dynamic_handler { 28 | // root 29 | // config 30 | // } 31 | // 32 | func (dh *DynamicHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 33 | if !d.Next() { 34 | return d.ArgErr() 35 | } 36 | 37 | if !d.NextArg() { 38 | return d.ArgErr() 39 | } 40 | dh.Name = d.Val() 41 | 42 | // Get the path of the Caddyfile. 43 | caddyfilePath, err := filepath.Abs(d.File()) 44 | if err != nil { 45 | return fmt.Errorf("failed to get absolute path of file: %s: %v", d.File(), err) 46 | } 47 | caddyfileDir := filepath.Dir(caddyfilePath) 48 | dh.Root = caddyfileDir // Defaults to the directory of the Caddyfile. 49 | 50 | for nesting := d.Nesting(); d.NextBlock(nesting); { 51 | switch d.Val() { 52 | case "root": 53 | if !d.NextArg() { 54 | return d.ArgErr() 55 | } 56 | root := d.Val() 57 | 58 | if filepath.IsAbs(root) { 59 | dh.Root = root 60 | return nil 61 | } 62 | 63 | // Make the path relative to the Caddyfile rather than the 64 | // current working directory. 65 | dh.Root = filepath.Join(caddyfileDir, root) 66 | 67 | case "config": 68 | if !d.NextArg() { 69 | return d.ArgErr() 70 | } 71 | dh.Config = d.Val() 72 | } 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // Interface guards 79 | var ( 80 | _ caddyfile.Unmarshaler = (*DynamicHandler)(nil) 81 | ) 82 | -------------------------------------------------------------------------------- /dynamichandler/README.md: -------------------------------------------------------------------------------- 1 | # dynamichandler 2 | 3 | A Caddy v2 extension to execute plugins (written in Go) dynamically by [Yaegi][1]. 4 | 5 | This means plugins do not need to be pre-compiled and linked into Caddy. 6 | 7 | 8 | ## Installation 9 | 10 | ```bash 11 | $ xcaddy build --with github.com/RussellLuo/caddy-ext/dynamichandler 12 | ``` 13 | 14 | ## Caddyfile Syntax 15 | 16 | ``` 17 | dynamic_handler { 18 | root 19 | config 20 | } 21 | ``` 22 | 23 | Parameters: 24 | 25 | - ``: The plugin name (as well as the Go package name). 26 | - ``: The root path of the plugin code. Defaults to the directory of the Caddyfile. 27 | + `` is an absolute path: `//.go` 28 | + `` is a relative path: `///.go` 29 | - ``: The plugin configuration in JSON format. 30 | 31 | 32 | ## Example 33 | 34 | Take the plugin [visitorip](plugins/visitorip/visitorip.go) as an example, with the following Caddyfile: 35 | 36 | ``` 37 | localhost:8080 { 38 | route /foo { 39 | dynamic_handler visitorip { 40 | root plugins 41 | config `{ 42 | "output": "stdout" 43 | }` 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | Access the `/foo` endpoint: 50 | 51 | ```bash 52 | $ curl 'https://localhost:8080/foo' 53 | ``` 54 | 55 | Then see the output of Caddy server: 56 | 57 | ```console 58 | ... 59 | 127.0.0.1:55255 60 | ... 61 | 62 | ``` 63 | 64 | ## Best Practices 65 | 66 | ### Using [snippets][2] to simplify Caddyfile 67 | 68 | Directory structure: 69 | 70 | ``` 71 | ├── Caddyfile 72 | └── plugins 73 | ├── plugin1 74 | │   └── plugin1.go 75 | └── plugin2 76 | └── plugin2.go 77 | ``` 78 | 79 | Caddyfile: 80 | 81 | ``` 82 | (dynamic) { 83 | dynamic_handler {args.0} { 84 | root plugins 85 | config {args.1} 86 | } 87 | } 88 | 89 | localhost:8080 { 90 | route /foo { 91 | import dynamic plugin1 `{ 92 | "arg1": "value1", 93 | "arg2": "value2" 94 | }` 95 | } 96 | 97 | route /bar { 98 | import dynamic plugin2 `` # no config 99 | } 100 | } 101 | ``` 102 | 103 | 104 | [1]: https://github.com/traefik/yaegi 105 | [2]: https://caddyserver.com/docs/caddyfile/concepts#snippets -------------------------------------------------------------------------------- /ratelimit/README.md: -------------------------------------------------------------------------------- 1 | # ratelimit 2 | 3 | A Caddy v2 extension to apply rate-limiting for HTTP requests. 4 | 5 | 6 | ## Installation 7 | 8 | ```bash 9 | $ xcaddy build --with github.com/RussellLuo/caddy-ext/ratelimit 10 | ``` 11 | 12 | ## Caddyfile Syntax 13 | 14 | ``` 15 | rate_limit [] [ []] 16 | ``` 17 | 18 | Parameters: 19 | 20 | - ``: The variable used to differentiate one client from another. Currently supported variables ([Caddy shorthand placeholders][1]): 21 | + `{path.}` 22 | + `{query.}` 23 | + `{header.}` 24 | + `{cookie.}` 25 | + `{body.}` (requires the [requestbodyvar](https://github.com/RussellLuo/caddy-ext/tree/master/requestbodyvar) extension) 26 | + `{remote.host}` (ignores the `X-Forwarded-For` header) 27 | + `{remote.port}` 28 | + `{remote.ip}` (prefers the first IP in the `X-Forwarded-For` header) 29 | + `{remote.host_prefix.}` (CIDR block version of `{remote.host}`) 30 | + `{remote.ip_prefix.}` (CIDR block version of `{remote.ip}`) 31 | - ``: The request rate limit (per key value) specified in requests per second (r/s) or requests per minute (r/m). 32 | - ``: The size (i.e. the number of key values) of the LRU zone that keeps states of these key values. Defaults to 10,000. 33 | - ``: The HTTP status code of the response when a client exceeds the rate limit. Defaults to 429 (Too Many Requests). 34 | 35 | 36 | ## Example 37 | 38 | With the following Caddyfile: 39 | 40 | ``` 41 | localhost:8080 { 42 | route /foo { 43 | rate_limit {query.id} 2r/m 44 | 45 | respond 200 46 | } 47 | } 48 | ``` 49 | 50 | You can apply the rate of `2 requests per minute` to the path `/foo`, and Caddy will respond with status code 429 when a client exceeds: 51 | 52 | ```bash 53 | $ curl -w "%{http_code}" 'https://localhost:8080/foo?id=1' 54 | 200 55 | $ curl -w "%{http_code}" 'https://localhost:8080/foo?id=1' 56 | 200 57 | $ curl -w "%{http_code}" 'https://localhost:8080/foo?id=1' 58 | 429 59 | ``` 60 | 61 | An extra request with other value for the request parameter `id` will not be limited: 62 | 63 | ```bash 64 | $ curl -w "%{http_code}" 'https://localhost:8080/foo?id=2' 65 | 200 66 | ``` 67 | 68 | 69 | [1]: https://caddyserver.com/docs/caddyfile/concepts#placeholders -------------------------------------------------------------------------------- /layer4/README.md: -------------------------------------------------------------------------------- 1 | # layer4 2 | 3 | The layer4 Caddyfile (via [global options](https://github.com/caddyserver/caddy/pull/3990)) for [mholt/caddy-l4](https://github.com/mholt/caddy-l4). 4 | 5 | Currently supported handlers: 6 | 7 | - `echo` (layer4.handlers.echo) 8 | - `proxy_protocol` (layer4.handlers.proxy_protocol) 9 | - `proxy` (layer4.handlers.proxy) 10 | - `tls` (layer4.handlers.tls) 11 | 12 | ## Installation 13 | 14 | ```bash 15 | $ xcaddy build --with github.com/RussellLuo/caddy-ext/layer4 16 | ``` 17 | 18 | ## Caddyfile Syntax 19 | 20 | ### The `layer4` global option 21 | 22 | ``` 23 | layer4 { 24 | # server 1 25 | { 26 | 27 | ... 28 | } 29 | 30 | # server 2 31 | { 32 | 33 | ... 34 | } 35 | } 36 | ``` 37 | 38 | ### Handlers 39 | 40 | The `echo` handler: 41 | 42 | ``` 43 | echo 44 | ``` 45 | 46 | The `proxy_protocol` handler: 47 | 48 | ``` 49 | proxy_protocol { 50 | timeout 51 | allow 52 | } 53 | ``` 54 | 55 | The `proxy` handler: 56 | 57 | ``` 58 | proxy [] { 59 | # backends 60 | to 61 | ... 62 | 63 | # load balancing 64 | lb_policy [] 65 | lb_try_duration 66 | lb_try_interval 67 | 68 | # active health checking 69 | health_port 70 | health_interval 71 | health_timeout 72 | 73 | # sending the PROXY protocol 74 | proxy_protocol 75 | } 76 | ``` 77 | 78 | The `tls` handler: 79 | 80 | ``` 81 | tls 82 | ``` 83 | 84 | ## Example 85 | 86 | With the following Caddyfile: 87 | 88 | ``` 89 | { 90 | layer4 { 91 | :8080 { 92 | proxy { 93 | to localhost:8081 localhost:8082 94 | lb_policy round_robin 95 | health_interval 5s 96 | } 97 | } 98 | } 99 | } 100 | 101 | :8081 { 102 | respond "This is 8081" 103 | } 104 | 105 | :8082 { 106 | respond "This is 8082" 107 | } 108 | ``` 109 | 110 | Requests to `:8080` will be forwarded to upstreams `:8081` and `:8082` in a round-robin policy: 111 | 112 | ```bash 113 | $ curl http://localhost:8080 114 | This is 8081 115 | $ curl http://localhost:8080 116 | This is 8082 117 | ``` 118 | -------------------------------------------------------------------------------- /dynamichandler/yaegisymbols/github_com-RussellLuo-caddy-ext-dynamichandler-caddymiddleware.go: -------------------------------------------------------------------------------- 1 | // Code generated by 'yaegi extract github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware'. DO NOT EDIT. 2 | 3 | package yaegisymbols 4 | 5 | import ( 6 | "github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware" 7 | "net/http" 8 | "reflect" 9 | ) 10 | 11 | func init() { 12 | Symbols["github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware/caddymiddleware"] = map[string]reflect.Value{ 13 | // type definitions 14 | "Handler": reflect.ValueOf((*caddymiddleware.Handler)(nil)), 15 | "HandlerFunc": reflect.ValueOf((*caddymiddleware.HandlerFunc)(nil)), 16 | "Middleware": reflect.ValueOf((*caddymiddleware.Middleware)(nil)), 17 | 18 | // interface wrapper definitions 19 | "_Handler": reflect.ValueOf((*_github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Handler)(nil)), 20 | "_Middleware": reflect.ValueOf((*_github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware)(nil)), 21 | } 22 | } 23 | 24 | // _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Handler is an interface wrapper for Handler type 25 | type _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Handler struct { 26 | IValue interface{} 27 | WServeHTTP func(a0 http.ResponseWriter, a1 *http.Request) error 28 | } 29 | 30 | func (W _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Handler) ServeHTTP(a0 http.ResponseWriter, a1 *http.Request) error { 31 | return W.WServeHTTP(a0, a1) 32 | } 33 | 34 | // _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware is an interface wrapper for Middleware type 35 | type _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware struct { 36 | IValue interface{} 37 | WCleanup func() error 38 | WProvision func() error 39 | WServeHTTP func(a0 http.ResponseWriter, a1 *http.Request, a2 caddymiddleware.Handler) error 40 | WValidate func() error 41 | } 42 | 43 | func (W _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware) Cleanup() error { 44 | return W.WCleanup() 45 | } 46 | func (W _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware) Provision() error { 47 | return W.WProvision() 48 | } 49 | func (W _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware) ServeHTTP(a0 http.ResponseWriter, a1 *http.Request, a2 caddymiddleware.Handler) error { 50 | return W.WServeHTTP(a0, a1, a2) 51 | } 52 | func (W _github_com_RussellLuo_caddy_ext_dynamichandler_caddymiddleware_Middleware) Validate() error { 53 | return W.WValidate() 54 | } 55 | -------------------------------------------------------------------------------- /flagr/caddyfile.go: -------------------------------------------------------------------------------- 1 | package flagr 2 | 3 | import ( 4 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 5 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 6 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 7 | ) 8 | 9 | func init() { 10 | httpcaddyfile.RegisterHandlerDirective("flagr", parseCaddyfile) 11 | } 12 | 13 | // parseCaddyfile sets up a handler for flagr from Caddyfile tokens. 14 | func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 15 | f := new(Flagr) 16 | if err := f.UnmarshalCaddyfile(h.Dispenser); err != nil { 17 | return nil, err 18 | } 19 | return f, nil 20 | } 21 | 22 | // UnmarshalCaddyfile implements caddyfile.Unmarshaler. Syntax: 23 | // 24 | // flagr { 25 | // evaluator [] 26 | // entity_id 27 | // entity_context { 28 | // 29 | // 30 | // ... 31 | // } 32 | // flag_keys ... 33 | // bind_variant_keys_to 34 | // } 35 | // 36 | func (f *Flagr) UnmarshalCaddyfile(d *caddyfile.Dispenser) (err error) { 37 | for d.Next() { 38 | if !d.NextArg() { 39 | return d.ArgErr() 40 | } 41 | f.URL = d.Val() 42 | 43 | for nesting := d.Nesting(); d.NextBlock(nesting); { 44 | switch d.Val() { 45 | case "evaluator": 46 | if d.NextArg() { 47 | f.Evaluator = d.Val() 48 | } 49 | if f.Evaluator == "local" { 50 | if d.NextArg() { 51 | f.RefreshInterval = d.Val() 52 | } 53 | } 54 | 55 | case "entity_id": 56 | if !d.NextArg() { 57 | return d.ArgErr() 58 | } 59 | f.EntityID = d.Val() 60 | 61 | case "entity_context": 62 | f.EntityContext = make(map[string]interface{}) 63 | for nesting := d.Nesting(); d.NextBlock(nesting); { 64 | key := d.Val() 65 | if !d.NextArg() { 66 | return d.ArgErr() 67 | } 68 | value := d.Val() 69 | 70 | if key == "" || value == "" { 71 | return d.Err("empty key or empty value within entity_context") 72 | } 73 | f.EntityContext[key] = value 74 | } 75 | 76 | case "flag_keys": 77 | for d.NextArg() { 78 | f.FlagKeys = append(f.FlagKeys, d.Val()) 79 | } 80 | if len(f.FlagKeys) == 0 { 81 | return d.ArgErr() 82 | } 83 | 84 | case "bind_variant_keys_to": 85 | if !d.NextArg() { 86 | return d.ArgErr() 87 | } 88 | f.BindVariantKeysTo = d.Val() 89 | } 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // Interface guards 97 | var ( 98 | _ caddyfile.Unmarshaler = (*Flagr)(nil) 99 | ) 100 | -------------------------------------------------------------------------------- /flagr/evaluator/remote.go: -------------------------------------------------------------------------------- 1 | package evaluator 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/checkr/flagr/swagger_gen/models" 8 | "github.com/checkr/goflagr" 9 | ) 10 | 11 | var ( 12 | onceRemote sync.Once 13 | singletonRemote *Remote 14 | ) 15 | 16 | type Remote struct { 17 | apiClient *goflagr.APIClient 18 | } 19 | 20 | func NewRemote(url string) *Remote { 21 | // Initialize the singleton once. 22 | onceRemote.Do(func() { 23 | singletonRemote = &Remote{ 24 | apiClient: goflagr.NewAPIClient(&goflagr.Configuration{ 25 | BasePath: url, 26 | DefaultHeader: make(map[string]string), 27 | UserAgent: "Caddy/go", 28 | }), 29 | } 30 | }) 31 | return singletonRemote 32 | } 33 | 34 | func (r *Remote) PostEvaluationBatch(ctx context.Context, req *models.EvaluationBatchRequest) (*models.EvaluationBatchResponse, error) { 35 | resp, _, err := r.apiClient.EvaluationApi.PostEvaluationBatch(ctx, toGoFlagrRequest(req)) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return fromGoFlagrResponse(resp), nil 40 | } 41 | 42 | func toGoFlagrRequest(req *models.EvaluationBatchRequest) (result goflagr.EvaluationBatchRequest) { 43 | for _, e := range req.Entities { 44 | result.Entities = append(result.Entities, goflagr.EvaluationEntity{ 45 | EntityID: e.EntityID, 46 | EntityContext: &e.EntityContext, 47 | }) 48 | } 49 | result.FlagKeys = req.FlagKeys 50 | return 51 | } 52 | 53 | func fromGoFlagrResponse(resp goflagr.EvaluationBatchResponse) *models.EvaluationBatchResponse { 54 | result := &models.EvaluationBatchResponse{} 55 | for _, r := range resp.EvaluationResults { 56 | result.EvaluationResults = append(result.EvaluationResults, &models.EvalResult{ 57 | FlagID: r.FlagID, 58 | FlagKey: r.FlagKey, 59 | FlagSnapshotID: r.FlagSnapshotID, 60 | SegmentID: r.SegmentID, 61 | VariantID: r.VariantID, 62 | VariantKey: r.VariantKey, 63 | VariantAttachment: *r.VariantAttachment, 64 | EvalContext: &models.EvalContext{ 65 | EntityID: r.EvalContext.EntityID, 66 | EntityType: r.EvalContext.EntityType, 67 | EntityContext: *r.EvalContext.EntityContext, 68 | EnableDebug: r.EvalContext.EnableDebug, 69 | FlagID: r.EvalContext.FlagID, 70 | FlagKey: r.EvalContext.FlagKey, 71 | }, 72 | Timestamp: r.Timestamp, 73 | EvalDebugLog: &models.EvalDebugLog{ 74 | Msg: r.EvalDebugLog.Msg, 75 | SegmentDebugLogs: convertSegmentDebugLogs(r.EvalDebugLog.SegmentDebugLogs), 76 | }, 77 | }) 78 | } 79 | return result 80 | } 81 | 82 | func convertSegmentDebugLogs(logs []goflagr.SegmentDebugLog) (result []*models.SegmentDebugLog) { 83 | for _, l := range logs { 84 | result = append(result, &models.SegmentDebugLog{ 85 | Msg: l.Msg, 86 | SegmentID: l.SegmentID, 87 | }) 88 | } 89 | return 90 | } 91 | -------------------------------------------------------------------------------- /dynamichandler/dynamichandler.go: -------------------------------------------------------------------------------- 1 | package dynamichandler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "go/build" 7 | "net/http" 8 | "path/filepath" 9 | 10 | "github.com/caddyserver/caddy/v2" 11 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 12 | "github.com/traefik/yaegi/interp" 13 | "github.com/traefik/yaegi/stdlib" 14 | "github.com/traefik/yaegi/stdlib/unsafe" 15 | "go.uber.org/zap" 16 | 17 | "github.com/RussellLuo/caddy-ext/dynamichandler/caddymiddleware" 18 | "github.com/RussellLuo/caddy-ext/dynamichandler/yaegisymbols" 19 | ) 20 | 21 | func init() { 22 | caddy.RegisterModule(DynamicHandler{}) 23 | } 24 | 25 | // DynamicHandler implements a handler that can execute plugins (written in Go) dynamically. 26 | type DynamicHandler struct { 27 | // The plugin name (as well as the Go package name). 28 | Name string `json:"name,omitempty"` 29 | 30 | // The root path of the plugin code. Defaults to the directory of the Caddyfile. 31 | // 32 | // The full path of the plugin code: 33 | // 34 | // - Root is an absolute path: `//.go` 35 | // - Root is a relative path: `///.go` 36 | Root string `json:"root,omitempty"` 37 | 38 | // The plugin configuration in JSON format. 39 | Config string `json:"config,omitempty"` 40 | 41 | middleware caddymiddleware.Middleware 42 | logger *zap.Logger 43 | } 44 | 45 | // CaddyModule returns the Caddy module information. 46 | func (DynamicHandler) CaddyModule() caddy.ModuleInfo { 47 | return caddy.ModuleInfo{ 48 | ID: "http.handlers.dynamic_handler", 49 | New: func() caddy.Module { return new(DynamicHandler) }, 50 | } 51 | } 52 | 53 | // Provision implements caddy.Provisioner. 54 | func (dh *DynamicHandler) Provision(ctx caddy.Context) error { 55 | dh.logger = ctx.Logger(dh) 56 | return dh.provision() 57 | } 58 | 59 | func (dh *DynamicHandler) provision() error { 60 | m, err := dh.eval() 61 | if err != nil { 62 | return err 63 | } 64 | 65 | dh.middleware = m 66 | return dh.middleware.Provision() 67 | } 68 | 69 | // Validate implements caddy.Validator. 70 | func (dh *DynamicHandler) Validate() error { 71 | return dh.middleware.Validate() 72 | } 73 | 74 | // Cleanup implements caddy.CleanerUpper. 75 | func (dh *DynamicHandler) Cleanup() error { 76 | if dh.middleware != nil { 77 | return dh.middleware.Cleanup() 78 | } 79 | return nil 80 | } 81 | 82 | // ServeHTTP implements caddyhttp.MiddlewareHandler. 83 | func (dh *DynamicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 84 | return dh.middleware.ServeHTTP(w, r, next) 85 | } 86 | 87 | func (dh *DynamicHandler) eval() (caddymiddleware.Middleware, error) { 88 | i := interp.New(interp.Options{GoPath: build.Default.GOPATH}) 89 | for _, exports := range []interp.Exports{ 90 | stdlib.Symbols, 91 | unsafe.Symbols, 92 | yaegisymbols.Symbols, 93 | } { 94 | if err := i.Use(exports); err != nil { 95 | return nil, err 96 | } 97 | } 98 | 99 | pkgPath := filepath.Join(dh.Root, dh.Name, dh.Name+".go") 100 | if _, err := i.EvalPath(pkgPath); err != nil { 101 | return nil, err 102 | } 103 | 104 | newFunc, err := i.Eval(dh.Name + ".New") 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | newMiddleware, ok := newFunc.Interface().(func() caddymiddleware.Middleware) 110 | if !ok { 111 | return nil, fmt.Errorf("%s.New does not implement `func() caddymiddleware.Middleware`", dh.Name) 112 | } 113 | middleware := newMiddleware() 114 | 115 | if len(dh.Config) > 0 { 116 | out := middleware.(yaegisymbols.Middleware).IValue 117 | if err := json.Unmarshal([]byte(dh.Config), out); err != nil { 118 | return nil, err 119 | } 120 | } 121 | 122 | return middleware, nil 123 | } 124 | 125 | // Interface guards 126 | var ( 127 | _ caddy.Provisioner = (*DynamicHandler)(nil) 128 | _ caddy.Validator = (*DynamicHandler)(nil) 129 | _ caddy.CleanerUpper = (*DynamicHandler)(nil) 130 | _ caddyhttp.MiddlewareHandler = (*DynamicHandler)(nil) 131 | ) 132 | -------------------------------------------------------------------------------- /requestbodyvar/requestbodyvar.go: -------------------------------------------------------------------------------- 1 | package requestbodyvar 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/caddyserver/caddy/v2" 12 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 13 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 14 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | const ( 19 | fullReqBodyReplPrefix = "http.request.body." 20 | shortReqBodyReplPrefix = "body." 21 | 22 | // For the request's buffered body 23 | bodyBufferCtxKey caddy.CtxKey = "body_buffer" 24 | ) 25 | 26 | func init() { 27 | caddy.RegisterModule(RequestBodyVar{}) 28 | httpcaddyfile.RegisterHandlerDirective("request_body_var", parseCaddyfile) 29 | } 30 | 31 | // RequestBodyVar implements an HTTP handler that replaces {http.request.body.*} 32 | // with the value of the given field from request body, if any. 33 | type RequestBodyVar struct { 34 | logger *zap.Logger 35 | } 36 | 37 | // CaddyModule returns the Caddy module information. 38 | func (RequestBodyVar) CaddyModule() caddy.ModuleInfo { 39 | return caddy.ModuleInfo{ 40 | ID: "http.handlers.request_body_var", 41 | New: func() caddy.Module { return new(RequestBodyVar) }, 42 | } 43 | } 44 | 45 | // Provision implements caddy.Provisioner. 46 | func (rbv *RequestBodyVar) Provision(ctx caddy.Context) (err error) { 47 | rbv.logger = ctx.Logger(rbv) 48 | return nil 49 | } 50 | 51 | // ServeHTTP implements caddyhttp.MiddlewareHandler. 52 | func (rbv RequestBodyVar) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 53 | bodyVars := func(key string) (interface{}, bool) { 54 | // We need to declare ctx before the use of the `goto`. 55 | // See https://github.com/golang/go/issues/27165 and https://github.com/golang/go/issues/26058. 56 | var ctx context.Context 57 | 58 | key, ok := parseKey(key) 59 | if !ok || key == "" { 60 | rbv.logger.Error("invalid var", zap.String("key", key)) 61 | return nil, false 62 | } 63 | 64 | // First of all, try to get the value from the buffered JSON body, if any. 65 | buf, ok := r.Context().Value(bodyBufferCtxKey).(*bytes.Buffer) 66 | if ok { 67 | rbv.logger.Debug("got from the buffer", zap.String("key", key)) 68 | goto Query 69 | } 70 | 71 | rbv.logger.Debug("got from the body", zap.String("key", key)) 72 | 73 | // Otherwise, try to get the value by reading the request body. 74 | if r == nil || r.Body == nil { 75 | return "", true 76 | } 77 | // Close the real body since we will replace it with a fake one. 78 | defer r.Body.Close() 79 | 80 | // Copy the request body. 81 | buf = new(bytes.Buffer) 82 | if _, err := io.Copy(buf, r.Body); err != nil { 83 | return "", true 84 | } 85 | 86 | // Replace the real body with buffered data. 87 | r.Body = ioutil.NopCloser(buf) 88 | 89 | // Add the buffered JSON body into the context for the request. 90 | ctx = context.WithValue(r.Context(), bodyBufferCtxKey, buf) 91 | r = r.WithContext(ctx) 92 | 93 | Query: 94 | querier, err := newQuerier(buf, r.Header.Get("Content-Type")) 95 | if err != nil { 96 | rbv.logger.Error("failed to new querier", zap.String("key", key), zap.Error(err)) 97 | return "", true 98 | } 99 | return querier.Query(key), true 100 | } 101 | 102 | repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) 103 | repl.Map(bodyVars) 104 | 105 | return next.ServeHTTP(w, r) 106 | } 107 | 108 | func parseKey(s string) (string, bool) { 109 | switch { 110 | case strings.HasPrefix(s, fullReqBodyReplPrefix): 111 | return s[len(fullReqBodyReplPrefix):], true 112 | case strings.HasPrefix(s, shortReqBodyReplPrefix): 113 | return s[len(shortReqBodyReplPrefix):], true 114 | default: 115 | // unrecognized 116 | return "", false 117 | } 118 | } 119 | 120 | // UnmarshalCaddyfile - this is a no-op 121 | func (rbv *RequestBodyVar) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 122 | return nil 123 | } 124 | 125 | func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 126 | rbv := new(RequestBodyVar) 127 | err := rbv.UnmarshalCaddyfile(h.Dispenser) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | return rbv, nil 133 | } 134 | 135 | // Interface guards 136 | var ( 137 | _ caddyhttp.MiddlewareHandler = (*RequestBodyVar)(nil) 138 | _ caddyfile.Unmarshaler = (*RequestBodyVar)(nil) 139 | ) 140 | -------------------------------------------------------------------------------- /flagr/README.md: -------------------------------------------------------------------------------- 1 | # flagr 2 | 3 | A Caddy v2 extension to apply [Feature Flags][1] for HTTP requests by using [Flagr][2]. 4 | 5 | 6 | ## Installation 7 | 8 | ```bash 9 | $ xcaddy build --with github.com/RussellLuo/caddy-ext/flagr 10 | ``` 11 | 12 | ## Caddyfile Syntax 13 | 14 | ``` 15 | flagr { 16 | evaluator [] 17 | entity_id 18 | entity_context { 19 | 20 | 21 | ... 22 | } 23 | flag_keys ... 24 | bind_variant_keys_to 25 | } 26 | ``` 27 | 28 | Parameters: 29 | 30 | - ``: The address of the Flagr server. 31 | - ``: Which evaluator to use. Defaults to `"local"`. Supported options: 32 | + `"local"` 33 | + `"remote"` 34 | - ``: The refresh interval of the internal eval cache (only used for the `"local"` evaluator). Defaults to `"10s"`. 35 | - ``: The unique ID from the entity, which is used to deterministically at random to evaluate the flag result. Defaults to `""` (Flagr [will randomly generate one][3]), and it must be a [Caddy variable][4] if provided: 36 | + `{path.}` 37 | + `{query.}` 38 | + `{header.}` 39 | + `{cookie.}` 40 | + `{body.}` (requires the [requestbodyvar](https://github.com/RussellLuo/caddy-ext/tree/master/requestbodyvar) extension) 41 | - ``: The context parameters (key-value pairs) of the entity, which is used to match the constraints. The value part may be a Caddy variable (see ``). 42 | - ``: A list of flag keys to look up. 43 | - ``: Which element of the request to bind the evaluated variant keys. Defaults to `"header.X-Flagr-Variant"`. Supported options: 44 | + `"header."` 45 | + `"query."` 46 | 47 | 48 | ## Example 49 | 50 | With the Flagr config and the Caddyfile as below: 51 | 52 | ![flagr-config](flagr-config.png) 53 | 54 | ``` 55 | localhost:8080 { 56 | route /foo { 57 | flagr http://127.0.0.1:18000/api/v1 { 58 | entity_id {query.id} 59 | entity_context { 60 | city CD 61 | } 62 | flag_keys demo 63 | } 64 | respond {header.X-Flagr-Variant} 200 65 | } 66 | route /bar { 67 | flagr http://127.0.0.1:18000/api/v1 { 68 | entity_id {query.id} 69 | entity_context { 70 | city BJ 71 | } 72 | flag_keys demo 73 | } 74 | respond {header.X-Flagr-Variant} 200 75 | } 76 | } 77 | ``` 78 | 79 | You can get the responses as follows: 80 | 81 | ```bash 82 | $ curl 'https://localhost:8080/foo?id=1' 83 | demo.on 84 | $ curl 'https://localhost:8080/bar?id=1' 85 | ``` 86 | 87 | 88 | ## Local Evaluator vs Remote Evaluator 89 | 90 | ### Internals 91 | 92 | ![local-vs-remote](local-vs-remote.png) 93 | 94 | ### Benchmarks 95 | 96 | Prerequisites: 97 | 98 | - Run Flagr locally [with docker][5] 99 | - Use the same Flagr config as in Example 100 | - Run Caddy with the following Caddyfile: 101 | 102 | ``` 103 | localhost:8080 { 104 | route /local { 105 | flagr http://127.0.0.1:18000/api/v1 { 106 | evaluator local 107 | entity_id {query.id} 108 | entity_context { 109 | city CD 110 | } 111 | flag_keys demo 112 | } 113 | respond 204 114 | } 115 | route /remote { 116 | flagr http://127.0.0.1:18000/api/v1 { 117 | evaluator remote 118 | entity_id {query.id} 119 | entity_context { 120 | city CD 121 | } 122 | flag_keys demo 123 | } 124 | respond 204 125 | } 126 | } 127 | ``` 128 | - Install the benchmarking tool [wrk][6] 129 | 130 | Here are the benchmark results I got on my MacBook: 131 | 132 | ```bash 133 | $ wrk -t15 -c200 -d30s 'https://localhost:8080/local?id=1' 134 | Running 30s test @ https://localhost:8080/local?id=1 135 | 15 threads and 200 connections 136 | Thread Stats Avg Stdev Max +/- Stdev 137 | Latency 7.14ms 8.83ms 161.49ms 90.13% 138 | Req/Sec 2.48k 396.11 8.44k 80.79% 139 | 1106860 requests in 30.10s, 83.39MB read 140 | Requests/sec: 36769.32 141 | Transfer/sec: 2.77MB 142 | ``` 143 | ```bash 144 | $ wrk -t15 -c200 -d30s 'https://localhost:8080/remote?id=1' 145 | Running 30s test @ https://localhost:8080/remote?id=1 146 | 15 threads and 200 connections 147 | Thread Stats Avg Stdev Max +/- Stdev 148 | Latency 48.68ms 97.87ms 1.20s 89.98% 149 | Req/Sec 778.59 239.79 1.66k 71.50% 150 | 348077 requests in 30.10s, 26.22MB read 151 | Requests/sec: 11564.13 152 | Transfer/sec: 0.87MB 153 | ``` 154 | 155 | 156 | [1]: https://martinfowler.com/articles/feature-toggles.html 157 | [2]: https://github.com/checkr/flagr 158 | [3]: https://checkr.github.io/flagr/api_docs/#operation/postEvaluation 159 | [4]: https://caddyserver.com/docs/caddyfile/concepts#placeholders 160 | [5]: https://checkr.github.io/flagr/#/home?id=run 161 | [6]: https://github.com/wg/wrk -------------------------------------------------------------------------------- /dynamichandler/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RussellLuo/caddy-ext/dynamichandler 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/caddyserver/caddy/v2 v2.5.1 7 | github.com/traefik/yaegi v0.13.0 8 | go.uber.org/zap v1.21.0 9 | ) 10 | 11 | require ( 12 | filippo.io/edwards25519 v1.0.0-rc.1 // indirect 13 | github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect 14 | github.com/Masterminds/goutils v1.1.1 // indirect 15 | github.com/Masterminds/semver/v3 v3.1.1 // indirect 16 | github.com/Masterminds/sprig/v3 v3.2.2 // indirect 17 | github.com/antlr/antlr4 v0.0.0-20200503195918-621b933c7a7f // indirect 18 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/caddyserver/certmagic v0.16.1 // indirect 21 | github.com/cespare/xxhash v1.1.0 // indirect 22 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 23 | github.com/cheekybits/genny v1.0.0 // indirect 24 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 25 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 26 | github.com/dgraph-io/badger v1.6.2 // indirect 27 | github.com/dgraph-io/badger/v2 v2.2007.4 // indirect 28 | github.com/dgraph-io/ristretto v0.0.4-0.20200906165740-41ebdbffecfd // indirect 29 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect 30 | github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac // indirect 31 | github.com/fsnotify/fsnotify v1.5.1 // indirect 32 | github.com/go-kit/kit v0.10.0 // indirect 33 | github.com/go-logfmt/logfmt v0.5.0 // indirect 34 | github.com/go-sql-driver/mysql v1.6.0 // indirect 35 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect 36 | github.com/golang/protobuf v1.5.2 // indirect 37 | github.com/golang/snappy v0.0.4 // indirect 38 | github.com/google/cel-go v0.7.3 // indirect 39 | github.com/google/uuid v1.3.0 // indirect 40 | github.com/huandu/xstrings v1.3.2 // indirect 41 | github.com/imdario/mergo v0.3.12 // indirect 42 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 43 | github.com/jackc/pgconn v1.10.1 // indirect 44 | github.com/jackc/pgio v1.0.0 // indirect 45 | github.com/jackc/pgpassfile v1.0.0 // indirect 46 | github.com/jackc/pgproto3/v2 v2.2.0 // indirect 47 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect 48 | github.com/jackc/pgtype v1.9.0 // indirect 49 | github.com/jackc/pgx/v4 v4.14.0 // indirect 50 | github.com/klauspost/compress v1.15.0 // indirect 51 | github.com/klauspost/cpuid/v2 v2.0.11 // indirect 52 | github.com/libdns/libdns v0.2.1 // indirect 53 | github.com/lucas-clemente/quic-go v0.26.0 // indirect 54 | github.com/manifoldco/promptui v0.9.0 // indirect 55 | github.com/marten-seemann/qpack v0.2.1 // indirect 56 | github.com/marten-seemann/qtls-go1-16 v0.1.5 // indirect 57 | github.com/marten-seemann/qtls-go1-17 v0.1.1 // indirect 58 | github.com/marten-seemann/qtls-go1-18 v0.1.1 // indirect 59 | github.com/mattn/go-colorable v0.1.8 // indirect 60 | github.com/mattn/go-isatty v0.0.13 // indirect 61 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 62 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 63 | github.com/mholt/acmez v1.0.2 // indirect 64 | github.com/micromdm/scep/v2 v2.1.0 // indirect 65 | github.com/miekg/dns v1.1.46 // indirect 66 | github.com/mitchellh/copystructure v1.2.0 // indirect 67 | github.com/mitchellh/go-ps v1.0.0 // indirect 68 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 69 | github.com/nxadm/tail v1.4.8 // indirect 70 | github.com/onsi/ginkgo v1.16.4 // indirect 71 | github.com/pkg/errors v0.9.1 // indirect 72 | github.com/prometheus/client_golang v1.12.1 // indirect 73 | github.com/prometheus/client_model v0.2.0 // indirect 74 | github.com/prometheus/common v0.32.1 // indirect 75 | github.com/prometheus/procfs v0.7.3 // indirect 76 | github.com/rs/xid v1.2.1 // indirect 77 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 78 | github.com/shopspring/decimal v1.2.0 // indirect 79 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 80 | github.com/sirupsen/logrus v1.8.1 // indirect 81 | github.com/slackhq/nebula v1.5.2 // indirect 82 | github.com/smallstep/certificates v0.19.0 // indirect 83 | github.com/smallstep/cli v0.18.0 // indirect 84 | github.com/smallstep/nosql v0.4.0 // indirect 85 | github.com/smallstep/truststore v0.11.0 // indirect 86 | github.com/spf13/cast v1.4.1 // indirect 87 | github.com/stoewer/go-strcase v1.2.0 // indirect 88 | github.com/tailscale/tscert v0.0.0-20220125204807-4509a5fbaf74 // indirect 89 | github.com/urfave/cli v1.22.5 // indirect 90 | go.etcd.io/bbolt v1.3.6 // indirect 91 | go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect 92 | go.step.sm/cli-utils v0.7.0 // indirect 93 | go.step.sm/crypto v0.16.1 // indirect 94 | go.step.sm/linkedca v0.15.0 // indirect 95 | go.uber.org/atomic v1.9.0 // indirect 96 | go.uber.org/multierr v1.6.0 // indirect 97 | golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2 // indirect 98 | golang.org/x/mod v0.4.2 // indirect 99 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect 100 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect 101 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 102 | golang.org/x/text v0.3.8-0.20211004125949-5bd84dd9b33b // indirect 103 | golang.org/x/tools v0.1.7 // indirect 104 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 105 | google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf // indirect 106 | google.golang.org/grpc v1.44.0 // indirect 107 | google.golang.org/protobuf v1.27.1 // indirect 108 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 109 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 110 | howett.net/plist v1.0.0 // indirect 111 | ) 112 | -------------------------------------------------------------------------------- /ratelimit/ratelimit_test.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/caddyserver/caddy/v2" 11 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func TestRateLimit_ServeHTTP(t *testing.T) { 16 | next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { 17 | w.WriteHeader(http.StatusOK) 18 | return nil 19 | }) 20 | 21 | cases := []struct { 22 | inRL *RateLimit 23 | inReq *http.Request 24 | wantStatusCodes []int 25 | }{ 26 | { 27 | inRL: &RateLimit{ 28 | Key: "{query.id}", 29 | Rate: "2r/m", 30 | logger: zap.NewNop(), 31 | }, 32 | inReq: httptest.NewRequest(http.MethodGet, "/foo?id=1", nil), 33 | wantStatusCodes: []int{ 34 | http.StatusOK, 35 | http.StatusOK, 36 | http.StatusTooManyRequests, 37 | }, 38 | }, 39 | } 40 | for _, c := range cases { 41 | _ = c.inRL.provision() 42 | //c.inRL.Cleanup() 43 | 44 | var gotStatusCodes []int 45 | for i := 0; i < len(c.wantStatusCodes); i++ { 46 | // Build the request object. 47 | repl := caddyhttp.NewTestReplacer(c.inReq) 48 | ctx := context.WithValue(c.inReq.Context(), caddy.ReplacerCtxKey, repl) 49 | req := c.inReq.WithContext(ctx) 50 | 51 | // Build the response object. 52 | w := httptest.NewRecorder() 53 | 54 | _ = c.inRL.ServeHTTP(w, req, next) 55 | 56 | // Collect the response status code. 57 | resp := w.Result() 58 | gotStatusCodes = append(gotStatusCodes, resp.StatusCode) 59 | } 60 | if !reflect.DeepEqual(gotStatusCodes, c.wantStatusCodes) { 61 | t.Fatalf("StatusCodes: got (%#v), want (%#v)", gotStatusCodes, c.wantStatusCodes) 62 | } 63 | } 64 | } 65 | 66 | func TestParseVar(t *testing.T) { 67 | cases := []struct { 68 | in string 69 | want *Var 70 | wantErrStr string 71 | }{ 72 | { 73 | in: "{path.id}", 74 | want: &Var{ 75 | Raw: "{path.id}", 76 | Name: "{http.request.uri.path.id}", 77 | }, 78 | }, 79 | { 80 | in: "{query.id}", 81 | want: &Var{ 82 | Raw: "{query.id}", 83 | Name: "{http.request.uri.query.id}", 84 | }, 85 | }, 86 | { 87 | in: "{header.id}", 88 | want: &Var{ 89 | Raw: "{header.id}", 90 | Name: "{http.request.header.id}", 91 | }, 92 | }, 93 | { 94 | in: "{cookie.id}", 95 | want: &Var{ 96 | Raw: "{cookie.id}", 97 | Name: "{http.request.cookie.id}", 98 | }, 99 | }, 100 | { 101 | in: "{remote.host}", 102 | want: &Var{ 103 | Raw: "{remote.host}", 104 | Name: "{http.request.remote.host}", 105 | }, 106 | }, 107 | { 108 | in: "{remote.port}", 109 | want: &Var{ 110 | Raw: "{remote.port}", 111 | Name: "{http.request.remote.port}", 112 | }, 113 | }, 114 | { 115 | in: "{remote.ip}", 116 | want: &Var{ 117 | Raw: "{remote.ip}", 118 | Name: "{http.request.remote.ip}", 119 | }, 120 | }, 121 | { 122 | in: "{remote.host_prefix.24}", 123 | want: &Var{ 124 | Raw: "{remote.host_prefix.24}", 125 | Name: "{http.request.remote.host_prefix}", 126 | Bits: 24, 127 | }, 128 | }, 129 | { 130 | in: "{remote.ip_prefix.64}", 131 | want: &Var{ 132 | Raw: "{remote.ip_prefix.64}", 133 | Name: "{http.request.remote.ip_prefix}", 134 | Bits: 64, 135 | }, 136 | }, 137 | { 138 | in: "{remote.host_prefix}", 139 | wantErrStr: `invalid key variable: "{remote.host_prefix}"`, 140 | }, 141 | { 142 | in: "{remote.ip_prefix.xx}", 143 | wantErrStr: `invalid key variable: "{remote.ip_prefix.xx}"`, 144 | }, 145 | { 146 | in: "{http.request.uri.path.id}", 147 | want: &Var{ 148 | Raw: "{http.request.uri.path.id}", 149 | Name: "{http.request.uri.path.id}", 150 | }, 151 | }, 152 | { 153 | in: "{unknown.id}", 154 | wantErrStr: `unrecognized key variable: "{unknown.id}"`, 155 | }, 156 | { 157 | in: "{unknown}", 158 | wantErrStr: `invalid key variable: "{unknown}"`, 159 | }, 160 | } 161 | 162 | for _, c := range cases { 163 | t.Run("", func(t *testing.T) { 164 | v, err := ParseVar(c.in) 165 | if err != nil && err.Error() != c.wantErrStr { 166 | t.Fatalf("ErrStr: got (%#v), want (%#v)", err.Error(), c.wantErrStr) 167 | } 168 | if !reflect.DeepEqual(v, c.want) { 169 | t.Fatalf("Out: got (%#v), want (%#v)", v, c.want) 170 | } 171 | }) 172 | } 173 | } 174 | 175 | func TestVar_Evaluate(t *testing.T) { 176 | cases := []struct { 177 | name string 178 | inVar *Var 179 | inReq func() *http.Request 180 | wantValue string 181 | wantErrStr string 182 | }{ 183 | { 184 | name: "query", 185 | inVar: &Var{ 186 | Name: "{http.request.uri.query.id}", 187 | }, 188 | inReq: func() *http.Request { 189 | return httptest.NewRequest(http.MethodGet, "/foo?id=1", nil) 190 | }, 191 | wantValue: "1", 192 | }, 193 | { 194 | name: "peer ip", 195 | inVar: &Var{ 196 | Name: "{http.request.remote.host}", 197 | }, 198 | inReq: func() *http.Request { 199 | req := httptest.NewRequest(http.MethodGet, "/", nil) 200 | req.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2") 201 | return req 202 | }, 203 | wantValue: "192.0.2.1", 204 | }, 205 | { 206 | name: "peer port", 207 | inVar: &Var{ 208 | Name: "{http.request.remote.port}", 209 | }, 210 | inReq: func() *http.Request { 211 | return httptest.NewRequest(http.MethodGet, "/", nil) 212 | }, 213 | wantValue: "1234", 214 | }, 215 | { 216 | name: "forwarded ip", 217 | inVar: &Var{ 218 | Name: "{http.request.remote.ip}", 219 | }, 220 | inReq: func() *http.Request { 221 | req := httptest.NewRequest(http.MethodGet, "/", nil) 222 | req.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2") 223 | return req 224 | }, 225 | wantValue: "192.168.0.1", 226 | }, 227 | { 228 | name: "ipv4 prefix", 229 | inVar: &Var{ 230 | Name: "{http.request.remote.ip_prefix}", 231 | Bits: 24, 232 | }, 233 | inReq: func() *http.Request { 234 | return httptest.NewRequest(http.MethodGet, "/", nil) 235 | }, 236 | wantValue: "192.0.2.0/24", 237 | }, 238 | { 239 | name: "ipv6 prefix", 240 | inVar: &Var{ 241 | Name: "{http.request.remote.ip_prefix}", 242 | Bits: 64, 243 | }, 244 | inReq: func() *http.Request { 245 | req := httptest.NewRequest(http.MethodGet, "/", nil) 246 | req.RemoteAddr = "[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443" 247 | return req 248 | }, 249 | wantValue: "2001:db8:85a3:8d3::/64", 250 | }, 251 | { 252 | name: "bad ipv4 prefix", 253 | inVar: &Var{ 254 | Name: "{http.request.remote.ip_prefix}", 255 | Bits: 64, 256 | }, 257 | inReq: func() *http.Request { 258 | return httptest.NewRequest(http.MethodGet, "/", nil) 259 | }, 260 | wantErrStr: "prefix length 64 too large for IPv4", 261 | }, 262 | } 263 | 264 | for _, c := range cases { 265 | t.Run(c.name, func(t *testing.T) { 266 | // Build the request object. 267 | r := c.inReq() 268 | repl := caddyhttp.NewTestReplacer(r) 269 | ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl) 270 | req := r.WithContext(ctx) 271 | 272 | value, err := c.inVar.Evaluate(req) 273 | if err != nil && err.Error() != c.wantErrStr { 274 | t.Fatalf("ErrStr: got (%#v), want (%#v)", err.Error(), c.wantErrStr) 275 | } 276 | if value != c.wantValue { 277 | t.Fatalf("Value: got (%#v), want (%#v)", value, c.wantValue) 278 | } 279 | }) 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /flagr/flagr.go: -------------------------------------------------------------------------------- 1 | package flagr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/caddyserver/caddy/v2" 12 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 13 | "github.com/checkr/flagr/swagger_gen/models" 14 | "go.uber.org/zap" 15 | 16 | "github.com/RussellLuo/caddy-ext/flagr/evaluator" 17 | ) 18 | 19 | var ( 20 | regexpFullVar = regexp.MustCompile(`^\{http\.request\..+\}$`) 21 | regexpShortVar = regexp.MustCompile(`^\{(\w+)\.(.+)\}$`) 22 | ) 23 | 24 | func init() { 25 | caddy.RegisterModule(Flagr{}) 26 | } 27 | 28 | type Evaluator interface { 29 | PostEvaluationBatch(ctx context.Context, req *models.EvaluationBatchRequest) (*models.EvaluationBatchResponse, error) 30 | } 31 | 32 | type ContextValue struct { 33 | Value interface{} 34 | IsCaddyVar bool 35 | Converters []Converter `json:"-"` 36 | } 37 | 38 | // Flagr implements a handler for applying Feature Flags for HTTP requests 39 | // by using checkr/flagr. 40 | type Flagr struct { 41 | // The address of the flagr server. 42 | URL string `json:"url,omitempty"` 43 | 44 | // Which evaluator to use. 45 | // Supported options: "local" or "remote". 46 | Evaluator string `json:"evaluator,omitempty"` 47 | // The refresh interval of the internal eval cache (only used for the "local" evaluator). 48 | RefreshInterval string `json:"refresh_interval,omitempty"` 49 | 50 | // The unique ID from the entity, which is used to deterministically at 51 | // random to evaluate the flag result. Must be a Caddy variable. 52 | EntityID string `json:"entity_id,omitempty"` 53 | 54 | // The context parameters (key-value pairs) from the entity, which is used 55 | // to match the constraints. 56 | EntityContext map[string]interface{} `json:"entity_context,omitempty"` 57 | 58 | // A list of flag keys to look up. 59 | FlagKeys []string `json:"flag_keys,omitempty"` 60 | 61 | // Which element of the request to bind the evaluated variant keys. 62 | // Supported options: "header.NAME" or "query.NAME". 63 | BindVariantKeysTo string `json:"bind_variant_keys_to,omitempty"` 64 | 65 | logger *zap.Logger 66 | evaluator Evaluator 67 | entityIDVar string 68 | entityContext map[string]ContextValue 69 | bindToLocation string 70 | bindToName string 71 | } 72 | 73 | // CaddyModule returns the Caddy module information. 74 | func (Flagr) CaddyModule() caddy.ModuleInfo { 75 | return caddy.ModuleInfo{ 76 | ID: "http.handlers.flagr", 77 | New: func() caddy.Module { return new(Flagr) }, // return a singleton. 78 | } 79 | } 80 | 81 | // Provision implements caddy.Provisioner. 82 | func (f *Flagr) Provision(ctx caddy.Context) (err error) { 83 | f.logger = ctx.Logger(f) 84 | return f.provision() 85 | } 86 | 87 | func (f *Flagr) provision() (err error) { 88 | if f.URL == "" { 89 | return fmt.Errorf("empty url") 90 | } 91 | 92 | refreshInterval := 10 * time.Second 93 | if f.RefreshInterval != "" { 94 | d, err := time.ParseDuration(f.RefreshInterval) 95 | if err != nil { 96 | return err 97 | } 98 | refreshInterval = d 99 | } 100 | 101 | if f.Evaluator == "" { 102 | f.Evaluator = "local" 103 | } 104 | switch f.Evaluator { 105 | case "local": 106 | f.evaluator, err = evaluator.NewLocal(refreshInterval, f.URL) 107 | if err != nil { 108 | return err 109 | } 110 | case "remote": 111 | f.evaluator = evaluator.NewRemote(f.URL) 112 | default: 113 | return fmt.Errorf("unsupported evaluator %q", f.Evaluator) 114 | } 115 | 116 | if f.EntityID != "" { 117 | f.entityIDVar, err = parseVar(f.EntityID) 118 | if err != nil { 119 | return err 120 | } 121 | } 122 | 123 | f.entityContext = make(map[string]ContextValue) 124 | for k, v := range f.EntityContext { 125 | cv := ContextValue{Value: v} 126 | 127 | // Handle string values specially. 128 | if s, ok := v.(string); ok { 129 | parts := strings.Split(s, "|") 130 | val := parts[0] 131 | 132 | if p, err := parseVar(val); err == nil { 133 | cv.Value = p 134 | cv.IsCaddyVar = true 135 | } else { 136 | cv.Value = val 137 | } 138 | 139 | for _, name := range parts[1:] { 140 | c, err := GetConverter(name) 141 | if err != nil { 142 | return err 143 | } 144 | cv.Converters = append(cv.Converters, c) 145 | } 146 | } 147 | 148 | f.entityContext[k] = cv 149 | } 150 | 151 | if f.BindVariantKeysTo == "" { 152 | f.BindVariantKeysTo = "header.X-Flagr-Variant" 153 | } 154 | 155 | parts := strings.SplitN(f.BindVariantKeysTo, ".", 2) 156 | if len(parts) != 2 { 157 | return fmt.Errorf("invalid bind_variant_key_to") 158 | } 159 | f.bindToLocation, f.bindToName = parts[0], parts[1] 160 | 161 | return nil 162 | } 163 | 164 | // Cleanup cleans up the resources made by rl during provisioning. 165 | func (f *Flagr) Cleanup() error { 166 | return nil 167 | } 168 | 169 | // Validate implements caddy.Validator. 170 | func (f *Flagr) Validate() error { 171 | if len(f.entityContext) == 0 { 172 | return fmt.Errorf("invalid entity_context") 173 | } 174 | if len(f.FlagKeys) == 0 { 175 | return fmt.Errorf("empty flag_keys") 176 | } 177 | 178 | if f.bindToLocation != "header" && f.bindToLocation != "query" { 179 | return fmt.Errorf("invalid location %q from bind_variant_key_to", f.bindToLocation) 180 | } 181 | if f.bindToName == "" { 182 | return fmt.Errorf("emtpy name from bind_variant_key_to") 183 | } 184 | 185 | return nil 186 | } 187 | 188 | // ServeHTTP implements caddyhttp.MiddlewareHandler. 189 | func (f *Flagr) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 190 | repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) 191 | entityID := repl.ReplaceAll(f.entityIDVar, "") 192 | if f.entityIDVar != "" && entityID == "" { 193 | f.logger.Info("entityID is evaluated to be empty", 194 | zap.Any("entityIDVar", f.entityIDVar), 195 | zap.Any("originalEntityContext", f.EntityContext), 196 | ) 197 | return next.ServeHTTP(w, r) 198 | } 199 | 200 | entityContext, err := evalEntityContext(f.entityContext, repl) 201 | if err != nil { 202 | f.logger.Error("failed to evaluate the entity context", 203 | zap.String("entityID", entityID), 204 | zap.Any("originalEntityContext", f.EntityContext), 205 | zap.Error(err), 206 | ) 207 | return next.ServeHTTP(w, r) 208 | } 209 | 210 | f.logger.Debug("ready to evaluate the request entity by Flagr", 211 | zap.String("entityID", entityID), 212 | zap.Any("entityContext", entityContext), 213 | ) 214 | 215 | resp, err := f.evaluator.PostEvaluationBatch(context.Background(), &models.EvaluationBatchRequest{ 216 | Entities: []*models.EvaluationEntity{ 217 | { 218 | EntityID: entityID, 219 | EntityContext: entityContext, 220 | }, 221 | }, 222 | FlagKeys: f.FlagKeys, 223 | }) 224 | if err != nil { 225 | f.logger.Error("failed to evaluate the request entity by Flagr", 226 | zap.String("entityID", entityID), 227 | zap.Any("originalEntityContext", f.EntityContext), 228 | zap.Error(err), 229 | ) 230 | return next.ServeHTTP(w, r) 231 | } 232 | 233 | for _, er := range resp.EvaluationResults { 234 | if er.VariantKey != "" { 235 | variant := er.FlagKey + "." + er.VariantKey 236 | switch f.bindToLocation { 237 | case "header": 238 | r.Header.Add(f.bindToName, variant) 239 | case "query": 240 | r.URL.Query().Add(f.bindToName, variant) 241 | } 242 | } 243 | } 244 | 245 | return next.ServeHTTP(w, r) 246 | } 247 | 248 | func evalEntityContext(entityCtx map[string]ContextValue, repl *caddy.Replacer) (interface{}, error) { 249 | out := make(map[string]interface{}) 250 | for k, cv := range entityCtx { 251 | v := cv.Value 252 | if cv.IsCaddyVar { 253 | // Use evaluated values for placeholders. 254 | v = repl.ReplaceAll(cv.Value.(string), "") 255 | } 256 | out[k] = v 257 | 258 | // If v is of type string, convert it by a list of converters, if any. 259 | if s, ok := v.(string); ok { 260 | for _, c := range cv.Converters { 261 | r, err := c(s) 262 | if err != nil { 263 | return nil, err 264 | } 265 | out[k] = r 266 | } 267 | } 268 | } 269 | return out, nil 270 | } 271 | 272 | // parseVar transforms shorthand variables into Caddy-style placeholders. 273 | // Copied from ratelimit/ratelimit.go. 274 | func parseVar(s string) (v string, err error) { 275 | if regexpFullVar.MatchString(s) { 276 | // If the variable is already a fully-qualified Caddy placeholder, 277 | // return it as is. 278 | return s, nil 279 | } 280 | 281 | result := regexpShortVar.FindStringSubmatch(s) 282 | if len(result) != 3 { 283 | return "", fmt.Errorf("invalid key variable: %q", s) 284 | } 285 | location, name := result[1], result[2] 286 | 287 | switch location { 288 | case "path": 289 | v = fmt.Sprintf("{http.request.uri.path.%s}", name) 290 | case "query": 291 | v = fmt.Sprintf("{http.request.uri.query.%s}", name) 292 | case "header": 293 | v = fmt.Sprintf("{http.request.header.%s}", name) 294 | case "cookie": 295 | v = fmt.Sprintf("{http.request.cookie.%s}", name) 296 | case "body": 297 | v = fmt.Sprintf("{http.request.body.%s}", name) 298 | default: 299 | err = fmt.Errorf("unrecognized key variable: %q", s) 300 | } 301 | 302 | return 303 | } 304 | 305 | // Interface guards 306 | var ( 307 | _ caddy.Provisioner = (*Flagr)(nil) 308 | _ caddy.CleanerUpper = (*Flagr)(nil) 309 | _ caddy.Validator = (*Flagr)(nil) 310 | _ caddyhttp.MiddlewareHandler = (*Flagr)(nil) 311 | ) 312 | -------------------------------------------------------------------------------- /ratelimit/ratelimit.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "net/netip" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/caddyserver/caddy/v2" 14 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | var ( 19 | regexpFullVar = regexp.MustCompile(`^\{http\.request\..+\}$`) 20 | regexpShortVar = regexp.MustCompile(`^\{(\w+)\.(.+)\}$`) 21 | // "host_prefix." or "ip_prefix." 22 | regexpPrefixVar = regexp.MustCompile(`^(host_prefix|ip_prefix)\.([0-9]+)$`) 23 | regexpRate = regexp.MustCompile(`^(\d+)r/(s|m)$`) 24 | ) 25 | 26 | func init() { 27 | caddy.RegisterModule(RateLimit{}) 28 | } 29 | 30 | // RateLimit implements a handler for rate-limiting. 31 | // 32 | // If a client exceeds the rate limit, an HTTP error with status `` will 33 | // be returned. This error can be handled using the conventional error handlers. 34 | // See [handle_errors](https://caddyserver.com/docs/caddyfile/directives/handle_errors) 35 | // for how to set up error handlers. 36 | type RateLimit struct { 37 | // The variable used to differentiate one client from another. 38 | // 39 | // Currently supported variables: 40 | // 41 | // - `{path.}` 42 | // - `{query.}` 43 | // - `{header.}` 44 | // - `{cookie.}` 45 | // - `{body.}` (requires the [requestbodyvar](https://github.com/RussellLuo/caddy-ext/tree/master/requestbodyvar) extension) 46 | // - `{remote.host}` (ignores the `X-Forwarded-For` header) 47 | // - `{remote.port}` 48 | // - `{remote.ip}` (prefers the first IP in the `X-Forwarded-For` header) 49 | // - `{remote.host_prefix.}` (CIDR block version of `{remote.host}`) 50 | // - `{remote.ip_prefix.}` (CIDR block version of `{remote.ip}`) 51 | Key string `json:"key,omitempty"` 52 | 53 | // The request rate limit (per key value) specified in requests 54 | // per second (r/s) or requests per minute (r/m). 55 | Rate string `json:"rate,omitempty"` 56 | 57 | // The size (i.e. the number of key values) of the LRU zone that 58 | // keeps states of these key values. Defaults to 10,000. 59 | ZoneSize int `json:"zone_size,omitempty"` 60 | 61 | // The HTTP status code of the response when a client exceeds the rate. 62 | // Defaults to 429 (Too Many Requests). 63 | RejectStatusCode int `json:"reject_status,omitempty"` 64 | 65 | keyVar *Var 66 | zone *Zone 67 | 68 | logger *zap.Logger 69 | } 70 | 71 | // CaddyModule returns the Caddy module information. 72 | func (RateLimit) CaddyModule() caddy.ModuleInfo { 73 | return caddy.ModuleInfo{ 74 | ID: "http.handlers.rate_limit", 75 | New: func() caddy.Module { return new(RateLimit) }, 76 | } 77 | } 78 | 79 | // Provision implements caddy.Provisioner. 80 | func (rl *RateLimit) Provision(ctx caddy.Context) (err error) { 81 | rl.logger = ctx.Logger(rl) 82 | return rl.provision() 83 | } 84 | 85 | func (rl *RateLimit) provision() (err error) { 86 | rl.keyVar, err = ParseVar(rl.Key) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | rateSize, rateLimit, err := parseRate(rl.Rate) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | if rl.ZoneSize == 0 { 97 | rl.ZoneSize = 10000 // At most 10,000 keys by default 98 | } 99 | 100 | rl.zone, err = NewZone(rl.ZoneSize, rateSize, int64(rateLimit)) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | if rl.RejectStatusCode == 0 { 106 | rl.RejectStatusCode = http.StatusTooManyRequests 107 | } 108 | 109 | return nil 110 | } 111 | 112 | // Cleanup cleans up the resources made by rl during provisioning. 113 | func (rl *RateLimit) Cleanup() error { 114 | if rl.zone != nil { 115 | rl.zone.Purge() 116 | } 117 | return nil 118 | } 119 | 120 | // Validate implements caddy.Validator. 121 | func (rl *RateLimit) Validate() error { 122 | if rl.keyVar == nil { 123 | return fmt.Errorf("no key variable") 124 | } 125 | if rl.zone == nil { 126 | return fmt.Errorf("no zone created") 127 | } 128 | if http.StatusText(rl.RejectStatusCode) == "" { 129 | return fmt.Errorf("unknown code reject_status: %d", rl.RejectStatusCode) 130 | } 131 | return nil 132 | } 133 | 134 | // ServeHTTP implements caddyhttp.MiddlewareHandler. 135 | func (rl *RateLimit) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 136 | keyValue, err := rl.keyVar.Evaluate(r) 137 | if err != nil { 138 | rl.logger.Error("failed to evaluate variable", 139 | zap.String("variable", rl.keyVar.Raw), 140 | zap.Error(err), 141 | ) 142 | return next.ServeHTTP(w, r) 143 | } 144 | 145 | w.Header().Add("RateLimit-Policy", rl.zone.RateLimitPolicyHeader()) 146 | 147 | if keyValue != "" && !rl.zone.Allow(keyValue) { 148 | rl.logger.Debug("request is rejected", 149 | zap.String("variable", rl.keyVar.Raw), 150 | zap.String("value", keyValue), 151 | ) 152 | 153 | w.WriteHeader(rl.RejectStatusCode) 154 | // Return an error to invoke possible error handlers. 155 | return caddyhttp.Error(rl.RejectStatusCode, nil) 156 | } 157 | 158 | return next.ServeHTTP(w, r) 159 | } 160 | 161 | type Var struct { 162 | Raw string 163 | Name string 164 | Bits int 165 | } 166 | 167 | // ParseVar transforms shorthand variables into Caddy-style placeholders. 168 | // 169 | // Examples for shorthand variables: 170 | // 171 | // - `{path.}` 172 | // - `{query.}` 173 | // - `{header.}` 174 | // - `{cookie.}` 175 | // - `{body.}` 176 | // - `{remote.host}` 177 | // - `{remote.port}` 178 | // - `{remote.ip}` 179 | // - `{remote.host_prefix.}` 180 | // - `{remote.ip_prefix.}` 181 | func ParseVar(s string) (*Var, error) { 182 | v := &Var{Raw: s} 183 | if regexpFullVar.MatchString(s) { 184 | // If the variable is already a fully-qualified Caddy placeholder, 185 | // return it as is. 186 | v.Name = s 187 | return v, nil 188 | } 189 | 190 | result := regexpShortVar.FindStringSubmatch(s) 191 | if len(result) != 3 { 192 | return nil, fmt.Errorf("invalid key variable: %q", s) 193 | } 194 | location, name := result[1], result[2] 195 | 196 | switch location { 197 | case "path": 198 | v.Name = fmt.Sprintf("{http.request.uri.path.%s}", name) 199 | case "query": 200 | v.Name = fmt.Sprintf("{http.request.uri.query.%s}", name) 201 | case "header": 202 | v.Name = fmt.Sprintf("{http.request.header.%s}", name) 203 | case "cookie": 204 | v.Name = fmt.Sprintf("{http.request.cookie.%s}", name) 205 | case "body": 206 | v.Name = fmt.Sprintf("{http.request.body.%s}", name) 207 | case "remote": 208 | if name == "host" || name == "port" || name == "ip" { 209 | v.Name = fmt.Sprintf("{http.request.remote.%s}", name) 210 | return v, nil 211 | } 212 | 213 | r := regexpPrefixVar.FindStringSubmatch(name) 214 | if len(r) != 3 { 215 | return nil, fmt.Errorf("invalid key variable: %q", s) 216 | } 217 | 218 | v.Name = fmt.Sprintf("{http.request.remote.%s}", r[1]) 219 | 220 | if r[2] == "" { 221 | return nil, fmt.Errorf("invalid key variable: %q", s) 222 | } 223 | bits, err := strconv.Atoi(r[2]) 224 | if err != nil { 225 | return nil, err 226 | } 227 | v.Bits = bits 228 | default: 229 | return nil, fmt.Errorf("unrecognized key variable: %q", s) 230 | } 231 | 232 | return v, nil 233 | } 234 | 235 | func (v *Var) Evaluate(r *http.Request) (value string, err error) { 236 | switch v.Name { 237 | case "{http.request.remote.ip}": 238 | ip, err := getClientIP(r, true) 239 | if err != nil { 240 | return "", err 241 | } 242 | return ip.String(), nil 243 | case "{http.request.remote.host_prefix}": 244 | return v.evaluatePrefix(r, false) 245 | case "{http.request.remote.ip_prefix}": 246 | return v.evaluatePrefix(r, true) 247 | default: 248 | repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) 249 | value = repl.ReplaceAll(v.Name, "") 250 | return value, nil 251 | } 252 | } 253 | 254 | func (v *Var) evaluatePrefix(r *http.Request, forwarded bool) (value string, err error) { 255 | ip, err := getClientIP(r, forwarded) 256 | if err != nil { 257 | return "", err 258 | } 259 | prefix, err := ip.Prefix(v.Bits) 260 | if err != nil { 261 | return "", err 262 | } 263 | return prefix.Masked().String(), nil 264 | } 265 | 266 | func getClientIP(r *http.Request, forwarded bool) (netip.Addr, error) { 267 | remote := r.RemoteAddr 268 | if forwarded { 269 | if fwdFor := r.Header.Get("X-Forwarded-For"); fwdFor != "" { 270 | remote = strings.TrimSpace(strings.Split(fwdFor, ",")[0]) 271 | } 272 | } 273 | ipStr, _, err := net.SplitHostPort(remote) 274 | if err != nil { 275 | ipStr = remote // OK; probably didn't have a port 276 | } 277 | return netip.ParseAddr(ipStr) 278 | } 279 | 280 | func parseRate(rate string) (size time.Duration, limit int, err error) { 281 | if rate == "" { 282 | return 0, 0, fmt.Errorf("missing rate") 283 | } 284 | 285 | result := regexpRate.FindStringSubmatch(rate) 286 | if len(result) != 3 { 287 | return 0, 0, fmt.Errorf("invalid rate: %s", rate) 288 | } 289 | limitStr, sizeStr := result[1], result[2] 290 | 291 | switch sizeStr { 292 | case "s": 293 | size = time.Second 294 | case "m": 295 | size = time.Minute 296 | } 297 | 298 | limit, err = strconv.Atoi(limitStr) 299 | if err != nil { 300 | return 0, 0, fmt.Errorf("size-limit must be an integer; invalid: %v", err) 301 | } 302 | 303 | return 304 | } 305 | 306 | // Interface guards 307 | var ( 308 | _ caddy.Provisioner = (*RateLimit)(nil) 309 | _ caddy.CleanerUpper = (*RateLimit)(nil) 310 | _ caddy.Validator = (*RateLimit)(nil) 311 | _ caddyhttp.MiddlewareHandler = (*RateLimit)(nil) 312 | ) 313 | -------------------------------------------------------------------------------- /layer4/caddyfile.go: -------------------------------------------------------------------------------- 1 | package layer4 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | 7 | "github.com/caddyserver/caddy/v2" 8 | "github.com/caddyserver/caddy/v2/caddyconfig" 9 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 10 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 11 | "github.com/mholt/caddy-l4/layer4" 12 | "github.com/mholt/caddy-l4/modules/l4echo" 13 | "github.com/mholt/caddy-l4/modules/l4proxy" 14 | "github.com/mholt/caddy-l4/modules/l4proxyprotocol" 15 | "github.com/mholt/caddy-l4/modules/l4tls" 16 | ) 17 | 18 | func init() { 19 | httpcaddyfile.RegisterGlobalOption("layer4", parseLayer4) 20 | } 21 | 22 | // parseLayer4 sets up the "layer4" global option from Caddyfile tokens. Syntax: 23 | // 24 | // layer4 { 25 | // { 26 | // l4echo 27 | // } 28 | // 29 | // { 30 | // l4proxy [] 31 | // } 32 | // } 33 | // 34 | func parseLayer4(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { 35 | app := &layer4.App{Servers: make(map[string]*layer4.Server)} 36 | 37 | for d.Next() { 38 | for i := 0; d.NextBlock(0); i++ { 39 | server := new(layer4.Server) 40 | 41 | server.Listen = append(server.Listen, d.Val()) 42 | for _, arg := range d.RemainingArgs() { 43 | server.Listen = append(server.Listen, arg) 44 | } 45 | 46 | for d.NextBlock(1) { 47 | switch d.Val() { 48 | case "l4echo", "echo": 49 | server.Routes = append(server.Routes, &layer4.Route{ 50 | HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(new(l4echo.Handler), "handler", "echo", nil)}, 51 | }) 52 | 53 | case "proxy_protocol": 54 | handler, err := parseProxyProtocol(d) 55 | if err != nil { 56 | return nil, err 57 | } 58 | server.Routes = append(server.Routes, &layer4.Route{ 59 | HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", "proxy_protocol", nil)}, 60 | }) 61 | 62 | case "l4proxy", "proxy": 63 | handler, err := parseProxy(d) 64 | if err != nil { 65 | return nil, err 66 | } 67 | server.Routes = append(server.Routes, &layer4.Route{ 68 | HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", "proxy", nil)}, 69 | }) 70 | case "l4tls", "tls": 71 | handler, err := parseTLS(d) 72 | if err != nil { 73 | return nil, err 74 | } 75 | server.Routes = append(server.Routes, &layer4.Route{ 76 | HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", "tls", nil)}, 77 | }) 78 | } 79 | } 80 | 81 | app.Servers["srv"+strconv.Itoa(i)] = server 82 | } 83 | } 84 | 85 | // tell Caddyfile adapter that this is the JSON for an app 86 | return httpcaddyfile.App{ 87 | Name: "layer4", 88 | Value: caddyconfig.JSON(app, nil), 89 | }, nil 90 | } 91 | 92 | // parseTLS sets up a "tls" handler from Caddyfile tokens. The implementation does 93 | // not support `connection_policies` because it does not implement `caddyfile.Unmarshaler`. Syntax: 94 | // 95 | // tls 96 | func parseTLS(_ *caddyfile.Dispenser) (*l4tls.Handler, error) { 97 | h := new(l4tls.Handler) 98 | return h, nil 99 | } 100 | 101 | // parseProxyProtocol sets up a "proxy_protocol" handler from Caddyfile tokens. Syntax: 102 | // 103 | // proxy_protocol { 104 | // timeout 105 | // allow 106 | // } 107 | // 108 | func parseProxyProtocol(d *caddyfile.Dispenser) (*l4proxyprotocol.Handler, error) { 109 | h := new(l4proxyprotocol.Handler) 110 | 111 | // No same-line options are supported 112 | if len(d.RemainingArgs()) > 0 { 113 | return nil, d.ArgErr() 114 | } 115 | 116 | for nesting := d.Nesting(); d.NextBlock(nesting); { 117 | switch d.Val() { 118 | case "timeout": 119 | if !d.NextArg() { 120 | return nil, d.ArgErr() 121 | } 122 | timeout, err := caddy.ParseDuration(d.Val()) 123 | if err != nil { 124 | return nil, d.Errf("parsing proxy_protocol timeout duration: %v", err) 125 | } 126 | h.Timeout = caddy.Duration(timeout) 127 | 128 | case "allow": 129 | args := d.RemainingArgs() 130 | if len(args) == 0 { 131 | return nil, d.ArgErr() 132 | } 133 | h.Allow = append(h.Allow, args...) 134 | 135 | default: 136 | return nil, d.ArgErr() 137 | } 138 | } 139 | 140 | return h, nil 141 | } 142 | 143 | // parseL4proxy sets up a "proxy" handler from Caddyfile tokens. Syntax: 144 | // 145 | // proxy [] { 146 | // # backends 147 | // to 148 | // ... 149 | // 150 | // # load balancing 151 | // lb_policy [] 152 | // lb_try_duration 153 | // lb_try_interval 154 | // 155 | // # active health checking 156 | // health_port 157 | // health_interval 158 | // health_timeout 159 | // 160 | // # sending the PROXY protocol 161 | // proxy_protocol 162 | // } 163 | // 164 | func parseProxy(d *caddyfile.Dispenser) (*l4proxy.Handler, error) { 165 | h := new(l4proxy.Handler) 166 | 167 | appendUpstream := func(addresses ...string) { 168 | for _, addr := range addresses { 169 | h.Upstreams = append(h.Upstreams, &l4proxy.Upstream{ 170 | Dial: []string{addr}, 171 | }) 172 | } 173 | } 174 | 175 | appendUpstream(d.RemainingArgs()...) 176 | 177 | for nesting := d.Nesting(); d.NextBlock(nesting); { 178 | switch d.Val() { 179 | case "to": 180 | args := d.RemainingArgs() 181 | if len(args) == 0 { 182 | return nil, d.ArgErr() 183 | } 184 | appendUpstream(args...) 185 | 186 | case "lb_policy": 187 | if !d.NextArg() { 188 | return nil, d.ArgErr() 189 | } 190 | if h.LoadBalancing != nil && h.LoadBalancing.SelectionPolicyRaw != nil { 191 | return nil, d.Err("load balancing selection policy already specified") 192 | } 193 | if h.LoadBalancing == nil { 194 | h.LoadBalancing = new(l4proxy.LoadBalancing) 195 | } 196 | 197 | name := d.Val() 198 | modID := "layer4.proxy.selection_policies." + name 199 | mod, err := UnmarshalL4proxySelectionModule(d, modID) 200 | if err != nil { 201 | return nil, err 202 | } 203 | 204 | sel, ok := mod.(l4proxy.Selector) 205 | if !ok { 206 | return nil, d.Errf("module %s (%T) is not a l4proxy.Selector", modID, mod) 207 | } 208 | h.LoadBalancing.SelectionPolicyRaw = caddyconfig.JSONModuleObject(sel, "policy", name, nil) 209 | 210 | case "lb_try_duration": 211 | if !d.NextArg() { 212 | return nil, d.ArgErr() 213 | } 214 | if h.LoadBalancing == nil { 215 | h.LoadBalancing = new(l4proxy.LoadBalancing) 216 | } 217 | 218 | dur, err := caddy.ParseDuration(d.Val()) 219 | if err != nil { 220 | return nil, d.Errf("bad duration value %s: %v", d.Val(), err) 221 | } 222 | h.LoadBalancing.TryDuration = caddy.Duration(dur) 223 | 224 | case "lb_try_interval": 225 | if !d.NextArg() { 226 | return nil, d.ArgErr() 227 | } 228 | if h.LoadBalancing == nil { 229 | h.LoadBalancing = new(l4proxy.LoadBalancing) 230 | } 231 | 232 | dur, err := caddy.ParseDuration(d.Val()) 233 | if err != nil { 234 | return nil, d.Errf("bad interval value '%s': %v", d.Val(), err) 235 | } 236 | h.LoadBalancing.TryInterval = caddy.Duration(dur) 237 | 238 | case "health_port": 239 | if !d.NextArg() { 240 | return nil, d.ArgErr() 241 | } 242 | if h.HealthChecks == nil { 243 | h.HealthChecks = new(l4proxy.HealthChecks) 244 | } 245 | if h.HealthChecks.Active == nil { 246 | h.HealthChecks.Active = new(l4proxy.ActiveHealthChecks) 247 | } 248 | 249 | portNum, err := strconv.Atoi(d.Val()) 250 | if err != nil { 251 | return nil, d.Errf("bad port number '%s': %v", d.Val(), err) 252 | } 253 | h.HealthChecks.Active.Port = portNum 254 | 255 | case "health_interval": 256 | if !d.NextArg() { 257 | return nil, d.ArgErr() 258 | } 259 | if h.HealthChecks == nil { 260 | h.HealthChecks = new(l4proxy.HealthChecks) 261 | } 262 | if h.HealthChecks.Active == nil { 263 | h.HealthChecks.Active = new(l4proxy.ActiveHealthChecks) 264 | } 265 | 266 | dur, err := caddy.ParseDuration(d.Val()) 267 | if err != nil { 268 | return nil, d.Errf("bad interval value %s: %v", d.Val(), err) 269 | } 270 | h.HealthChecks.Active.Interval = caddy.Duration(dur) 271 | 272 | case "health_timeout": 273 | if !d.NextArg() { 274 | return nil, d.ArgErr() 275 | } 276 | if h.HealthChecks == nil { 277 | h.HealthChecks = new(l4proxy.HealthChecks) 278 | } 279 | if h.HealthChecks.Active == nil { 280 | h.HealthChecks.Active = new(l4proxy.ActiveHealthChecks) 281 | } 282 | 283 | dur, err := caddy.ParseDuration(d.Val()) 284 | if err != nil { 285 | return nil, d.Errf("bad timeout value %s: %v", d.Val(), err) 286 | } 287 | h.HealthChecks.Active.Timeout = caddy.Duration(dur) 288 | 289 | case "proxy_protocol": 290 | if !d.NextArg() { 291 | return nil, d.ArgErr() 292 | } 293 | h.ProxyProtocol = d.Val() 294 | } 295 | } 296 | 297 | return h, nil 298 | } 299 | 300 | // UnmarshalL4proxySelectionModule is like `caddyfile.UnmarshalModule`, but for 301 | // l4proxy's selection modules, which do not implement `caddyfile.Unmarshaler` yet. 302 | func UnmarshalL4proxySelectionModule(d *caddyfile.Dispenser, moduleID string) (caddy.Module, error) { 303 | mod, err := caddy.GetModule(moduleID) 304 | if err != nil { 305 | return nil, d.Errf("getting module named '%s': %v", moduleID, err) 306 | } 307 | inst := mod.New() 308 | 309 | if err = UnmarshalL4ProxySelectionCaddyfile(inst, d.NewFromNextSegment()); err != nil { 310 | return nil, err 311 | } 312 | return inst, nil 313 | } 314 | 315 | func UnmarshalL4ProxySelectionCaddyfile(inst caddy.Module, d *caddyfile.Dispenser) error { 316 | switch sel := inst.(type) { 317 | case *l4proxy.RandomSelection, 318 | *l4proxy.LeastConnSelection, 319 | *l4proxy.RoundRobinSelection, 320 | *l4proxy.FirstSelection, 321 | *l4proxy.IPHashSelection: 322 | 323 | for d.Next() { 324 | if d.NextArg() { 325 | return d.ArgErr() 326 | } 327 | } 328 | 329 | case *l4proxy.RandomChoiceSelection: 330 | for d.Next() { 331 | if !d.NextArg() { 332 | return d.ArgErr() 333 | } 334 | chooseStr := d.Val() 335 | choose, err := strconv.Atoi(chooseStr) 336 | if err != nil { 337 | return d.Errf("invalid choice value '%s': %v", chooseStr, err) 338 | } 339 | sel.Choose = choose 340 | } 341 | } 342 | 343 | return nil 344 | } 345 | --------------------------------------------------------------------------------