From 03306e646e3ee421f582ef69a2158724bcf2614b Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:10:00 -0600 Subject: admin: /config and /id endpoints This integrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The /config and /id endpoints make granular config changes possible as well as the exporting of the current configuration. The /load endpoint has been modified to wrap the /config handler so that the currently-running config can always be available for export. The difference is that /load allows configs of varying formats and converts them using config adapters. The adapted config is then processed with /config as JSON. The /config and /id endpoints accept only JSON. --- admin.go | 20 +-- caddyconfig/configadapters.go | 8 +- dynamicconfig.go | 358 ++++++++++++++++++++++++++++++++++++++++++ dynamicconfig_test.go | 123 +++++++++++++++ 4 files changed, 493 insertions(+), 16 deletions(-) create mode 100644 dynamicconfig.go create mode 100644 dynamicconfig_test.go diff --git a/admin.go b/admin.go index 860ed05..db8b700 100644 --- a/admin.go +++ b/admin.go @@ -87,7 +87,7 @@ func StartAdmin(initialConfigJSON []byte) error { return fmt.Errorf("parsing admin listener address: %v", err) } if len(listenAddrs) != 1 { - return fmt.Errorf("admin endpoint must have exactly one listener; cannot listen on %v", listenAddrs) + return fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs) } ln, err := net.Listen(netw, listenAddrs[0]) if err != nil { @@ -120,7 +120,7 @@ func StartAdmin(initialConfigJSON []byte) error { ReadTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 5 * time.Second, - MaxHeaderBytes: 1024 * 256, + MaxHeaderBytes: 1024 * 64, } go cfgEndptSrv.Serve(ln) @@ -169,14 +169,11 @@ type AdminRoute struct { } func handleLoadConfig(w http.ResponseWriter, r *http.Request) { - r.Close = true if r.Method != http.MethodPost { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } - var payload io.Reader = r.Body - // if the config is formatted other than Caddy's native // JSON, we need to adapt it before loading it if ctHeader := r.Header.Get("Content-Type"); ctHeader != "" { @@ -215,16 +212,15 @@ func handleLoadConfig(w http.ResponseWriter, r *http.Request) { } w.Write(respBody) } - payload = bytes.NewReader(result) + // replace original request body with adapted JSON + r.Body.Close() + r.Body = ioutil.NopCloser(bytes.NewReader(result)) } } - err := Load(payload) - if err != nil { - log.Printf("[ADMIN][ERROR] loading config: %v", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + // pass this off to the /config/ endpoint + r.URL.Path = "/" + rawConfigKey + "/" + handleConfig(w, r) } func handleStop(w http.ResponseWriter, r *http.Request) { diff --git a/caddyconfig/configadapters.go b/caddyconfig/configadapters.go index 1a0801f..071221f 100644 --- a/caddyconfig/configadapters.go +++ b/caddyconfig/configadapters.go @@ -27,10 +27,10 @@ type Adapter interface { // Warning represents a warning or notice related to conversion. type Warning struct { - File string - Line int - Directive string - Message string + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Directive string `json:"directive,omitempty"` + Message string `json:"message,omitempty"` } // JSON encodes val as JSON, returning it as a json.RawMessage. Any diff --git a/dynamicconfig.go b/dynamicconfig.go new file mode 100644 index 0000000..0e44376 --- /dev/null +++ b/dynamicconfig.go @@ -0,0 +1,358 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddy + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +func init() { + RegisterModule(router{}) +} + +type router []AdminRoute + +// CaddyModule returns the Caddy module information. +func (router) CaddyModule() ModuleInfo { + return ModuleInfo{ + Name: "admin.routers.dynamic_config", + New: func() Module { + return router{ + { + Pattern: "/" + rawConfigKey + "/", + Handler: http.HandlerFunc(handleConfig), + }, + { + Pattern: "/id/", + Handler: http.HandlerFunc(handleConfigID), + }, + } + }, + } +} + +func (r router) Routes() []AdminRoute { return r } + +// handleConfig handles config changes or exports according to r. +// This function is safe for concurrent use. +func handleConfig(w http.ResponseWriter, r *http.Request) { + rawCfgMu.Lock() + defer rawCfgMu.Unlock() + unsyncedHandleConfig(w, r) +} + +// handleConfigID accesses the config through a user-assigned ID +// that is mapped to its full/expanded path in the JSON structure. +// It is the same as handleConfig except it replaces the ID in +// the request path with the full, expanded URL path. +// This function is safe for concurrent use. +func handleConfigID(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 3 || parts[2] == "" { + http.Error(w, "request path is missing object ID", http.StatusBadRequest) + return + } + id := parts[2] + + rawCfgMu.Lock() + defer rawCfgMu.Unlock() + + // map the ID to the expanded path + expanded, ok := rawCfgIndex[id] + if !ok { + http.Error(w, "unknown object ID: "+id, http.StatusBadRequest) + return + } + + // piece the full URL path back together + parts = append([]string{expanded}, parts[3:]...) + r.URL.Path = path.Join(parts...) + + unsyncedHandleConfig(w, r) +} + +// configIndex recurisvely searches ptr for object fields named "@id" +// and maps that ID value to the full configPath in the index. +// This function is NOT safe for concurrent access; use rawCfgMu. +func configIndex(ptr interface{}, configPath string, index map[string]string) error { + switch val := ptr.(type) { + case map[string]interface{}: + for k, v := range val { + if k == "@id" { + switch idVal := v.(type) { + case string: + index[idVal] = configPath + case float64: // all JSON numbers decode as float64 + index[fmt.Sprintf("%v", idVal)] = configPath + default: + return fmt.Errorf("%s: @id field must be a string or number", configPath) + } + delete(val, "@id") // field is no longer needed, and will break config if not removed + continue + } + // traverse this object property recursively + err := configIndex(val[k], path.Join(configPath, k), index) + if err != nil { + return err + } + } + case []interface{}: + // traverse each element of the array recursively + for i := range val { + err := configIndex(val[i], path.Join(configPath, strconv.Itoa(i)), index) + if err != nil { + return err + } + } + } + + return nil +} + +// unsycnedHandleConfig handles config accesses without a lock +// on rawCfgMu. This is NOT safe for concurrent use, so be sure +// to acquire a lock on rawCfgMu before calling this. +func unsyncedHandleConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + // perform the mutation with our decoded representation + // (the map), which may change pointers deep within it + err := mutateConfig(w, r) + if err != nil { + http.Error(w, "mutating config: "+err.Error(), http.StatusBadRequest) + return + } + + if r.Method != http.MethodGet { + // find any IDs in this config and index them + idx := make(map[string]string) + err = configIndex(rawCfg[rawConfigKey], "/config", idx) + if err != nil { + http.Error(w, "indexing config: "+err.Error(), http.StatusInternalServerError) + return + } + + // the mutation is complete, so encode the entire config as JSON + newCfg, err := json.Marshal(rawCfg[rawConfigKey]) + if err != nil { + http.Error(w, "encoding new config: "+err.Error(), http.StatusBadRequest) + return + } + + // if nothing changed, no need to do a whole reload unless the client forces it + if r.Header.Get("Cache-Control") != "must-revalidate" && bytes.Equal(rawCfgJSON, newCfg) { + log.Printf("[ADMIN][INFO] Config is unchanged") + return + } + + // load this new config; if it fails, we need to revert to + // our old representation of caddy's actual config + err = Load(bytes.NewReader(newCfg)) + if err != nil { + // restore old config state to keep it consistent + // with what caddy is still running; we need to + // unmarshal it again because it's likely that + // pointers deep in our rawCfg map were modified + var oldCfg interface{} + err2 := json.Unmarshal(rawCfgJSON, &oldCfg) + if err2 != nil { + err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2) + } + rawCfg[rawConfigKey] = oldCfg + + // report error + log.Printf("[ADMIN][ERROR] loading config: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // success, so update our stored copy of the encoded + // config to keep it consistent with what caddy is now + // running (storing an encoded copy is not strictly + // necessary, but avoids an extra json.Marshal for + // each config change) + rawCfgJSON = newCfg + rawCfgIndex = idx + } +} + +// mutateConfig changes the rawCfg according to r. It is NOT +// safe for concurrent use; use rawCfgMu. If the request's +// method is GET, the config will not be changed. +func mutateConfig(w http.ResponseWriter, r *http.Request) error { + var err error + var val interface{} + + // if there is a request body, make sure we recognize its content-type and decode it + if r.Method != http.MethodGet && r.Method != http.MethodDelete { + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "/json") { + return fmt.Errorf("unacceptable content-type: %v; 'application/json' required", ct) + } + err = json.NewDecoder(r.Body).Decode(&val) + if err != nil { + return fmt.Errorf("decoding request body: %v", err) + } + } + + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + + cleanPath := strings.Trim(r.URL.Path, "/") + if cleanPath == "" { + return fmt.Errorf("no traversable path") + } + + parts := strings.Split(cleanPath, "/") + if len(parts) == 0 { + return fmt.Errorf("path missing") + } + + var ptr interface{} = rawCfg + +traverseLoop: + for i, part := range parts { + switch v := ptr.(type) { + case map[string]interface{}: + // if the next part enters a slice, and the slice is our destination, + // handle it specially (because appending to the slice copies the slice + // header, which does not replace the original one like we want) + if arr, ok := v[part].([]interface{}); ok && i == len(parts)-2 { + var idx int + if r.Method != http.MethodPost { + idxStr := parts[len(parts)-1] + idx, err = strconv.Atoi(idxStr) + if err != nil { + return fmt.Errorf("[%s] invalid array index '%s': %v", + r.URL.Path, idxStr, err) + } + if idx < 0 || idx >= len(arr) { + return fmt.Errorf("[%s] array index out of bounds: %s", r.URL.Path, idxStr) + } + } + + switch r.Method { + case http.MethodGet: + err = enc.Encode(arr[idx]) + if err != nil { + return fmt.Errorf("encoding config: %v", err) + } + case http.MethodPost: + v[part] = append(arr, val) + case http.MethodPut: + // avoid creation of new slice and a second copy (see + // https://github.com/golang/go/wiki/SliceTricks#insert) + arr = append(arr, nil) + copy(arr[idx+1:], arr[idx:]) + arr[idx] = val + v[part] = arr + case http.MethodPatch: + arr[idx] = val + case http.MethodDelete: + v[part] = append(arr[:idx], arr[idx+1:]...) + default: + return fmt.Errorf("unrecognized method %s", r.Method) + } + break traverseLoop + } + + if i == len(parts)-1 { + switch r.Method { + case http.MethodGet: + err = enc.Encode(v[part]) + if err != nil { + return fmt.Errorf("encoding config: %v", err) + } + case http.MethodPost: + if arr, ok := v[part].([]interface{}); ok { + // if the part is an existing list, POST appends to it + // TODO: Do we ever reach this point, since we handle arrays + // separately above? + v[part] = append(arr, val) + } else { + // otherwise, it simply sets the value + v[part] = val + } + case http.MethodPut: + if _, ok := v[part]; ok { + return fmt.Errorf("[%s] key already exists: %s", r.URL.Path, part) + } + v[part] = val + case http.MethodPatch: + if _, ok := v[part]; !ok { + return fmt.Errorf("[%s] key does not exist: %s", r.URL.Path, part) + } + v[part] = val + case http.MethodDelete: + delete(v, part) + default: + return fmt.Errorf("unrecognized method %s", r.Method) + } + } else { + ptr = v[part] + } + + case []interface{}: + partInt, err := strconv.Atoi(part) + if err != nil { + return fmt.Errorf("[/%s] invalid array index '%s': %v", + strings.Join(parts[:i+1], "/"), part, err) + } + if partInt < 0 || partInt >= len(v) { + return fmt.Errorf("[/%s] array index out of bounds: %s", + strings.Join(parts[:i+1], "/"), part) + } + ptr = v[partInt] + + default: + return fmt.Errorf("invalid path: %s", parts[:i+1]) + } + } + + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + w.Write(buf.Bytes()) + } + + return nil +} + +var ( + // rawCfg is the current, generic-decoded configuration; + // we initialize it as a map with one field ("config") + // to maintain parity with the API endpoint and to avoid + // the special case of having to access/mutate the variable + // directly without traversing into it + rawCfg = map[string]interface{}{ + rawConfigKey: nil, + } + rawCfgJSON []byte // keeping the encoded form avoids an extra Marshal on changes + rawCfgIndex map[string]string // map of user-assigned ID to expanded path + rawCfgMu sync.Mutex // protects rawCfg, rawCfgJSON, and rawCfgIndex +) + +const rawConfigKey = "config" diff --git a/dynamicconfig_test.go b/dynamicconfig_test.go new file mode 100644 index 0000000..372c756 --- /dev/null +++ b/dynamicconfig_test.go @@ -0,0 +1,123 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestMutateConfig(t *testing.T) { + // each test is performed in sequence, so + // each change builds on the previous ones; + // the config is not reset between tests + for i, tc := range []struct { + method string + path string // rawConfigKey will be prepended + payload string + expect string // JSON representation of what the whole config is expected to be after the request + shouldErr bool + }{ + { + method: "POST", + path: "", + payload: `{"foo": "bar", "list": ["a", "b", "c"]}`, // starting value + expect: `{"foo": "bar", "list": ["a", "b", "c"]}`, + }, + { + method: "POST", + path: "/foo", + payload: `"jet"`, + expect: `{"foo": "jet", "list": ["a", "b", "c"]}`, + }, + { + method: "POST", + path: "/bar", + payload: `{"aa": "bb", "qq": "zz"}`, + expect: `{"foo": "jet", "bar": {"aa": "bb", "qq": "zz"}, "list": ["a", "b", "c"]}`, + }, + { + method: "DELETE", + path: "/bar/qq", + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`, + }, + { + method: "POST", + path: "/list", + payload: `"e"`, + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`, + }, + { + method: "PUT", + path: "/list/3", + payload: `"d"`, + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d", "e"]}`, + }, + { + method: "DELETE", + path: "/list/3", + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "e"]}`, + }, + { + method: "PATCH", + path: "/list/3", + payload: `"d"`, + expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c", "d"]}`, + }, + } { + req, err := http.NewRequest(tc.method, rawConfigKey+tc.path, strings.NewReader(tc.payload)) + if err != nil { + t.Fatalf("Test %d: making test request: %v", i, err) + } + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + + err = mutateConfig(w, req) + + if tc.shouldErr && err == nil { + t.Fatalf("Test %d: Expected error return value, but got: %v", i, err) + } + if !tc.shouldErr && err != nil { + t.Fatalf("Test %d: Should not have had error return value, but got: %v", i, err) + } + + if tc.shouldErr && w.Code == http.StatusOK { + t.Fatalf("Test %d: Expected error, but got HTTP %d: %s", + i, w.Code, w.Body.String()) + } + if !tc.shouldErr && w.Code != http.StatusOK { + t.Fatalf("Test %d: Should not have errored, but got HTTP %d: %s", + i, w.Code, w.Body.String()) + } + + // decode the expected config so we can do a convenient DeepEqual + var expectedDecoded interface{} + err = json.Unmarshal([]byte(tc.expect), &expectedDecoded) + if err != nil { + t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err) + } + + // make sure the resulting config is as we expect it + if !reflect.DeepEqual(rawCfg[rawConfigKey], expectedDecoded) { + t.Fatalf("Test %d:\nExpected:\n\t%#v\nActual:\n\t%#v", + i, expectedDecoded, rawCfg[rawConfigKey]) + } + } +} -- cgit v1.2.3 From a53b27c62e792e09380876db916289c77f2ae2e0 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:22:46 -0600 Subject: http: Add work-in-progress cache handler module This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The cache HTTP handler will be a high-performing, distributed cache layer for HTTP requests. Right now, the implementation is a very basic proof-of-concept, and further development is required. --- cmd/caddy/main.go | 1 + modules/caddyhttp/httpcache/httpcache.go | 205 +++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 modules/caddyhttp/httpcache/httpcache.go diff --git a/cmd/caddy/main.go b/cmd/caddy/main.go index 93e95e7..87226cd 100644 --- a/cmd/caddy/main.go +++ b/cmd/caddy/main.go @@ -28,6 +28,7 @@ import ( _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" + _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/httpcache" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/markdown" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/requestbody" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy" diff --git a/modules/caddyhttp/httpcache/httpcache.go b/modules/caddyhttp/httpcache/httpcache.go new file mode 100644 index 0000000..1b2cfd2 --- /dev/null +++ b/modules/caddyhttp/httpcache/httpcache.go @@ -0,0 +1,205 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpcache + +import ( + "bytes" + "encoding/gob" + "fmt" + "io" + "log" + "net/http" + "sync" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/golang/groupcache" +) + +func init() { + caddy.RegisterModule(Cache{}) +} + +// Cache implements a simple distributed cache. +type Cache struct { + group *groupcache.Group +} + +// CaddyModule returns the Caddy module information. +func (Cache) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.cache", + New: func() caddy.Module { return new(Cache) }, + } +} + +// Provision provisions c. +func (c *Cache) Provision(ctx caddy.Context) error { + // TODO: proper pool configuration + me := "http://localhost:5555" + // TODO: Make max size configurable + maxSize := int64(512 << 20) + poolMu.Lock() + if pool == nil { + pool = groupcache.NewHTTPPool(me) + c.group = groupcache.NewGroup(groupName, maxSize, groupcache.GetterFunc(c.getter)) + } else { + c.group = groupcache.GetGroup(groupName) + } + pool.Set(me) + poolMu.Unlock() + + return nil +} + +// Validate validates c. +func (c *Cache) Validate() error { + // TODO: implement + return nil +} + +func (c *Cache) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + // TODO: proper RFC implementation of cache control headers... + if r.Header.Get("Cache-Control") == "no-cache" || r.Method != "GET" { + return next.ServeHTTP(w, r) + } + + ctx := getterContext{w, r, next} + + // TODO: rigorous performance testing + + // TODO: pretty much everything else to handle the nuances of HTTP caching... + + // TODO: groupcache has no explicit cache eviction, so we need to embed + // all information related to expiring cache entries into the key; right + // now we just use the request URI as a proof-of-concept + key := r.RequestURI + + var cachedBytes []byte + err := c.group.Get(ctx, key, groupcache.AllocatingByteSliceSink(&cachedBytes)) + if err == errUncacheable { + return nil + } + if err != nil { + return err + } + + // the cached bytes consists of two parts: first a + // gob encoding of the status and header, immediately + // followed by the raw bytes of the response body + rdr := bytes.NewReader(cachedBytes) + + // read the header and status first + var hs headerAndStatus + err = gob.NewDecoder(rdr).Decode(&hs) + if err != nil { + return err + } + + // set and write the cached headers + for k, v := range hs.Header { + w.Header()[k] = v + } + w.WriteHeader(hs.Status) + + // write the cached response body + io.Copy(w, rdr) + + return nil +} + +func (c *Cache) getter(ctx groupcache.Context, key string, dest groupcache.Sink) error { + combo := ctx.(getterContext) + + // the buffer will store the gob-encoded header, then the body + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufPool.Put(buf) + + // we need to record the response if we are to cache it; only cache if + // request is successful (TODO: there's probably much more nuance needed here) + var rr caddyhttp.ResponseRecorder + rr = caddyhttp.NewResponseRecorder(combo.rw, buf, func(status int) bool { + shouldBuf := status < 300 + + if shouldBuf { + // store the header before the body, so we can efficiently + // and conveniently use a single buffer for both; gob + // decoder will only read up to end of gob message, and + // the rest will be the body, which will be written + // implicitly for us by the recorder + err := gob.NewEncoder(buf).Encode(headerAndStatus{ + Header: rr.Header(), + Status: status, + }) + if err != nil { + log.Printf("[ERROR] Encoding headers for cache entry: %v; not caching this request", err) + return false + } + } + + return shouldBuf + }) + + // execute next handlers in chain + err := combo.next.ServeHTTP(rr, combo.req) + if err != nil { + return err + } + + // if response body was not buffered, response was + // already written and we are unable to cache + if !rr.Buffered() { + return errUncacheable + } + + // add to cache + dest.SetBytes(buf.Bytes()) + + return nil +} + +type headerAndStatus struct { + Header http.Header + Status int +} + +type getterContext struct { + rw http.ResponseWriter + req *http.Request + next caddyhttp.Handler +} + +var bufPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +var ( + pool *groupcache.HTTPPool + poolMu sync.Mutex +) + +var errUncacheable = fmt.Errorf("uncacheable") + +const groupName = "http_requests" + +// Interface guards +var ( + _ caddy.Provisioner = (*Cache)(nil) + _ caddy.Validator = (*Cache)(nil) + _ caddyhttp.MiddlewareHandler = (*Cache)(nil) +) -- cgit v1.2.3 From bcbe1c220de99146a13bcc786ec7f5017681de73 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:28:07 -0600 Subject: reverse_proxy: Add local circuit breaker This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The local circuit breaker is a simple metrics counter that can cause the reverse proxy to consider a backend unhealthy before it actually goes offline, by measuring recent latencies over a sliding window. Credit to Danny Navarro --- modules/caddyhttp/reverseproxy/circuitbreaker.go | 152 +++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 modules/caddyhttp/reverseproxy/circuitbreaker.go diff --git a/modules/caddyhttp/reverseproxy/circuitbreaker.go b/modules/caddyhttp/reverseproxy/circuitbreaker.go new file mode 100644 index 0000000..de2a6f9 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/circuitbreaker.go @@ -0,0 +1,152 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/vulcand/oxy/memmetrics" +) + +func init() { + caddy.RegisterModule(localCircuitBreaker{}) +} + +// localCircuitBreaker implements circuit breaking functionality +// for requests within this process over a sliding time window. +type localCircuitBreaker struct { + tripped int32 + cbType int32 + threshold float64 + metrics *memmetrics.RTMetrics + tripTime time.Duration + Config +} + +// CaddyModule returns the Caddy module information. +func (localCircuitBreaker) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.reverse_proxy.circuit_breakers.local", + New: func() caddy.Module { return new(localCircuitBreaker) }, + } +} + +// Provision sets up a configured circuit breaker. +func (c *localCircuitBreaker) Provision(ctx caddy.Context) error { + t, ok := typeCB[c.Type] + if !ok { + return fmt.Errorf("type is not defined") + } + + if c.TripTime == "" { + c.TripTime = defaultTripTime + } + + tw, err := time.ParseDuration(c.TripTime) + if err != nil { + return fmt.Errorf("cannot parse trip_time duration, %v", err.Error()) + } + + mt, err := memmetrics.NewRTMetrics() + if err != nil { + return fmt.Errorf("cannot create new metrics: %v", err.Error()) + } + + c.cbType = t + c.tripTime = tw + c.threshold = c.Threshold + c.metrics = mt + c.tripped = 0 + + return nil +} + +// Ok returns whether the circuit breaker is tripped or not. +func (c *localCircuitBreaker) Ok() bool { + tripped := atomic.LoadInt32(&c.tripped) + return tripped == 0 +} + +// RecordMetric records a response status code and execution time of a request. This function should be run in a separate goroutine. +func (c *localCircuitBreaker) RecordMetric(statusCode int, latency time.Duration) { + c.metrics.Record(statusCode, latency) + c.checkAndSet() +} + +// Ok checks our metrics to see if we should trip our circuit breaker, or if the fallback duration has completed. +func (c *localCircuitBreaker) checkAndSet() { + var isTripped bool + + switch c.cbType { + case typeErrorRatio: + // check if amount of network errors exceed threshold over sliding window, threshold for comparison should be < 1.0 i.e. .5 = 50th percentile + if c.metrics.NetworkErrorRatio() > c.threshold { + isTripped = true + } + case typeLatency: + // check if threshold in milliseconds is reached and trip + hist, err := c.metrics.LatencyHistogram() + if err != nil { + return + } + + l := hist.LatencyAtQuantile(c.threshold) + if l.Nanoseconds()/int64(time.Millisecond) > int64(c.threshold) { + isTripped = true + } + case typeStatusCodeRatio: + // check ratio of error status codes of sliding window, threshold for comparison should be < 1.0 i.e. .5 = 50th percentile + if c.metrics.ResponseCodeRatio(500, 600, 0, 600) > c.threshold { + isTripped = true + } + } + + if isTripped { + c.metrics.Reset() + atomic.AddInt32(&c.tripped, 1) + + // wait tripTime amount before allowing operations to resume. + t := time.NewTimer(c.tripTime) + <-t.C + + atomic.AddInt32(&c.tripped, -1) + } +} + +// Config represents the configuration of a circuit breaker. +type Config struct { + Threshold float64 `json:"threshold"` + Type string `json:"type"` + TripTime string `json:"trip_time"` +} + +const ( + typeLatency = iota + 1 + typeErrorRatio + typeStatusCodeRatio + defaultTripTime = "5s" +) + +var ( + // typeCB handles converting a Config Type value to the internal circuit breaker types. + typeCB = map[string]int32{ + "latency": typeLatency, + "error_ratio": typeErrorRatio, + "status_ratio": typeStatusCodeRatio, + } +) -- cgit v1.2.3 From 20fe9cf024898c73725eab0d4306dfd3b6ccd6d8 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:34:14 -0600 Subject: tls: Add pem_loader module This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. The PEM loader allows you to embed PEM files (certificates and keys) directly into your config, rather than requiring them to be stored on potentially insecure storage, which adds attack vectors. This is useful in automated settings where sensitive key material is stored only in memory. Note that if the config is persisted to disk, that added benefit may go away, but there will still be the benefit of having lesser dependence on external files. --- modules/caddytls/pemloader.go | 65 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 modules/caddytls/pemloader.go diff --git a/modules/caddytls/pemloader.go b/modules/caddytls/pemloader.go new file mode 100644 index 0000000..30a491c --- /dev/null +++ b/modules/caddytls/pemloader.go @@ -0,0 +1,65 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddytls + +import ( + "crypto/tls" + "fmt" + + "github.com/caddyserver/caddy/v2" +) + +func init() { + caddy.RegisterModule(PEMLoader{}) +} + +// PEMLoader loads certificates and their associated keys by +// decoding their PEM blocks directly. This has the advantage +// of not needing to store them on disk at all. +type PEMLoader []CertKeyPEMPair + +// CaddyModule returns the Caddy module information. +func (PEMLoader) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "tls.certificates.load_pem", + New: func() caddy.Module { return PEMLoader{} }, + } +} + +// CertKeyPEMPair pairs certificate and key PEM blocks. +type CertKeyPEMPair struct { + CertificatePEM string `json:"certificate"` + KeyPEM string `json:"key"` + Tags []string `json:"tags,omitempty"` +} + +// LoadCertificates returns the certificates contained in pl. +func (pl PEMLoader) LoadCertificates() ([]Certificate, error) { + var certs []Certificate + for i, pair := range pl { + cert, err := tls.X509KeyPair([]byte(pair.CertificatePEM), []byte(pair.KeyPEM)) + if err != nil { + return nil, fmt.Errorf("PEM pair %d: %v", i, err) + } + certs = append(certs, Certificate{ + Certificate: cert, + Tags: pair.Tags, + }) + } + return certs, nil +} + +// Interface guard +var _ CertificateLoader = (PEMLoader)(nil) -- cgit v1.2.3 From dedcfd4e3da1297cda2d09776e7c8135e0d960ce Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:38:26 -0600 Subject: tls: Add distributed_stek module This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. TLS session ticket keys are sensitive, so they should be rotated on a regular basis. Only Caddy does this by default. However, a cluster of servers that rotate keys without synchronization will lose the benefits of having sessions in the first place if the client is routed to a different backend. This module coordinates STEK rotation in a fleet so the same keys are used, and rotated, across the whole cluster. No other server does this, but Twitter wrote about how they hacked together a solution a few years ago: https://blog.twitter.com/engineering/en_us/a/2013/forward-secrecy-at-twitter.html --- .../caddytls/distributedstek/distributedstek.go | 228 +++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 modules/caddytls/distributedstek/distributedstek.go diff --git a/modules/caddytls/distributedstek/distributedstek.go b/modules/caddytls/distributedstek/distributedstek.go new file mode 100644 index 0000000..a0c4cd2 --- /dev/null +++ b/modules/caddytls/distributedstek/distributedstek.go @@ -0,0 +1,228 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package distributedstek provides TLS session ticket ephemeral +// keys (STEKs) in a distributed fashion by utilizing configured +// storage for locking and key sharing. This allows a cluster of +// machines to optimally resume TLS sessions in a load-balanced +// environment without any hassle. This is similar to what +// Twitter does, but without needing to rely on SSH, as it is +// built into the web server this way: +// https://blog.twitter.com/engineering/en_us/a/2013/forward-secrecy-at-twitter.html +package distributedstek + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddytls" + "github.com/mholt/certmagic" +) + +func init() { + caddy.RegisterModule(Provider{}) +} + +// Provider implements a distributed STEK provider. +type Provider struct { + Storage json.RawMessage `json:"storage,omitempty"` + + storage certmagic.Storage + stekConfig *caddytls.SessionTicketService + timer *time.Timer +} + +// CaddyModule returns the Caddy module information. +func (Provider) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "tls.stek.distributed", + New: func() caddy.Module { return new(Provider) }, + } +} + +// Provision provisions s. +func (s *Provider) Provision(ctx caddy.Context) error { + // unpack the storage module to use, if different from the default + if s.Storage != nil { + val, err := ctx.LoadModuleInline("module", "caddy.storage", s.Storage) + if err != nil { + return fmt.Errorf("loading TLS storage module: %s", err) + } + cmStorage, err := val.(caddy.StorageConverter).CertMagicStorage() + if err != nil { + return fmt.Errorf("creating TLS storage configuration: %v", err) + } + s.storage = cmStorage + s.Storage = nil // allow GC to deallocate + } + + // otherwise, use default storage + if s.storage == nil { + s.storage = ctx.Storage() + } + + return nil +} + +// Initialize sets the configuration for s and returns the starting keys. +func (s *Provider) Initialize(config *caddytls.SessionTicketService) ([][32]byte, error) { + // keep a reference to the config; we'll need it when rotating keys + s.stekConfig = config + + dstek, err := s.getSTEK() + if err != nil { + return nil, err + } + + // create timer for the remaining time on the interval; + // this timer is cleaned up only when rotate() returns + s.timer = time.NewTimer(time.Until(dstek.NextRotation)) + + return dstek.Keys, nil +} + +// Next returns a channel which transmits the latest session ticket keys. +func (s *Provider) Next(doneChan <-chan struct{}) <-chan [][32]byte { + keysChan := make(chan [][32]byte) + go s.rotate(doneChan, keysChan) + return keysChan +} + +func (s *Provider) loadSTEK() (distributedSTEK, error) { + var sg distributedSTEK + gobBytes, err := s.storage.Load(stekFileName) + if err != nil { + return sg, err // don't wrap, in case error is certmagic.ErrNotExist + } + dec := gob.NewDecoder(bytes.NewReader(gobBytes)) + err = dec.Decode(&sg) + if err != nil { + return sg, fmt.Errorf("STEK gob corrupted: %v", err) + } + return sg, nil +} + +func (s *Provider) storeSTEK(dstek distributedSTEK) error { + var buf bytes.Buffer + err := gob.NewEncoder(&buf).Encode(dstek) + if err != nil { + return fmt.Errorf("encoding STEK gob: %v", err) + } + err = s.storage.Store(stekFileName, buf.Bytes()) + if err != nil { + return fmt.Errorf("storing STEK gob: %v", err) + } + return nil +} + +// getSTEK locks and loads the current STEK from storage. If none +// currently exists, a new STEK is created and persisted. If the +// current STEK is outdated (NextRotation time is in the past), +// then it is rotated and persisted. The resulting STEK is returned. +func (s *Provider) getSTEK() (distributedSTEK, error) { + s.storage.Lock(stekLockName) + defer s.storage.Unlock(stekLockName) + + // load the current STEKs from storage + dstek, err := s.loadSTEK() + if _, isNotExist := err.(certmagic.ErrNotExist); isNotExist { + // if there is none, then make some right away + dstek, err = s.rotateKeys(dstek) + if err != nil { + return dstek, fmt.Errorf("creating new STEK: %v", err) + } + } else if err != nil { + // some other error, that's a problem + return dstek, fmt.Errorf("loading STEK: %v", err) + } else if time.Now().After(dstek.NextRotation) { + // if current STEKs are outdated, rotate them + dstek, err = s.rotateKeys(dstek) + if err != nil { + return dstek, fmt.Errorf("rotating keys: %v", err) + } + } + + return dstek, nil +} + +// rotateKeys rotates the keys of oldSTEK and returns the new distributedSTEK +// with updated keys and timestamps. It stores the returned STEK in storage, +// so this function must only be called in a storage-provided lock. +func (s *Provider) rotateKeys(oldSTEK distributedSTEK) (distributedSTEK, error) { + var newSTEK distributedSTEK + var err error + + newSTEK.Keys, err = s.stekConfig.RotateSTEKs(oldSTEK.Keys) + if err != nil { + return newSTEK, err + } + + now := time.Now() + newSTEK.LastRotation = now + newSTEK.NextRotation = now.Add(time.Duration(s.stekConfig.RotationInterval)) + + err = s.storeSTEK(newSTEK) + if err != nil { + return newSTEK, err + } + + return newSTEK, nil +} + +// rotate rotates keys on a regular basis, sending each updated set of +// keys down keysChan, until doneChan is closed. +func (s *Provider) rotate(doneChan <-chan struct{}, keysChan chan<- [][32]byte) { + for { + select { + case <-s.timer.C: + dstek, err := s.getSTEK() + if err != nil { + // TODO: improve this handling + log.Printf("[ERROR] Loading STEK: %v", err) + continue + } + + // send the updated keys to the service + keysChan <- dstek.Keys + + // timer channel is already drained, so reset directly (see godoc) + s.timer.Reset(time.Until(dstek.NextRotation)) + + case <-doneChan: + // again, see godocs for why timer is stopped this way + if !s.timer.Stop() { + <-s.timer.C + } + return + } + } +} + +type distributedSTEK struct { + Keys [][32]byte + LastRotation, NextRotation time.Time +} + +const ( + stekLockName = "stek_check" + stekFileName = "stek/stek.bin" +) + +// Interface guard +var _ caddytls.STEKProvider = (*Provider)(nil) -- cgit v1.2.3 From 85ce15a5ad0c0a0e24ed026b1f06a5a573c74283 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 19:41:45 -0600 Subject: tls: Add custom certificate selection policy This migrates a feature that was previously reserved for enterprise users, according to https://github.com/caddyserver/caddy/issues/2786. Custom certificate selection policies allow advanced control over which cert is selected when multiple qualify to satisfy a TLS handshake. --- modules/caddytls/certselection.go | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 modules/caddytls/certselection.go diff --git a/modules/caddytls/certselection.go b/modules/caddytls/certselection.go new file mode 100644 index 0000000..b56185a --- /dev/null +++ b/modules/caddytls/certselection.go @@ -0,0 +1,71 @@ +package caddytls + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "math/big" + + "github.com/caddyserver/caddy/v2" + "github.com/mholt/certmagic" +) + +func init() { + caddy.RegisterModule(Policy{}) +} + +// Policy represents a policy for selecting the certificate used to +// complete a handshake when there may be multiple options. All fields +// specified must match the candidate certificate for it to be chosen. +// This was needed to solve https://github.com/caddyserver/caddy/issues/2588. +type Policy struct { + SerialNumber *big.Int `json:"serial_number,omitempty"` + SubjectOrganization string `json:"subject_organization,omitempty"` + PublicKeyAlgorithm PublicKeyAlgorithm `json:"public_key_algorithm,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// CaddyModule returns the Caddy module information. +func (Policy) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "tls.certificate_selection.custom", + New: func() caddy.Module { return new(Policy) }, + } +} + +// SelectCertificate implements certmagic.CertificateSelector. +func (p Policy) SelectCertificate(_ *tls.ClientHelloInfo, choices []certmagic.Certificate) (certmagic.Certificate, error) { + for _, cert := range choices { + if p.SerialNumber != nil && cert.SerialNumber.Cmp(p.SerialNumber) != 0 { + continue + } + + if p.PublicKeyAlgorithm != PublicKeyAlgorithm(x509.UnknownPublicKeyAlgorithm) && + PublicKeyAlgorithm(cert.PublicKeyAlgorithm) != p.PublicKeyAlgorithm { + continue + } + + if p.SubjectOrganization != "" { + var matchOrg bool + for _, org := range cert.Subject.Organization { + if p.SubjectOrganization == org { + matchOrg = true + break + } + } + if !matchOrg { + continue + } + } + + if p.Tag != "" && !cert.HasTag(p.Tag) { + continue + } + + return cert, nil + } + return certmagic.Certificate{}, fmt.Errorf("no certificates matched custom selection policy") +} + +// Interface guard +var _ certmagic.CertificateSelector = (*Policy)(nil) -- cgit v1.2.3 From 93943a6ac28fa7f100e355f4a9b4ab05dc2d1cb7 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Wed, 9 Oct 2019 20:19:00 -0600 Subject: readme: Remove mentions of Caddy Enterprise (as per #2786) --- README.md | 50 ++++++++++++++++---------------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index aef9df9..7aaf57e 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Caddy 2 Development Branch This is the development branch for Caddy 2. This code (version 2) is not yet feature-complete or production-ready, but is already being used in production, and we encourage you to deploy it today on sites that are not very visible or important so that it can obtain crucial experience in the field. -Please file issues to propose new features and report bugs, and after the bug or feature has been discussed, submit a pull request! We need your help to build this web server into what you want it to be. (Caddy 2 issues and pull requests will usually receive priority over Caddy 1 issues and pull requests.) +Please file issues to propose new features and report bugs, and after the bug or feature has been discussed, submit a pull request! We need your help to build this web server into what you want it to be. (Caddy 2 issues and pull requests receive priority over Caddy 1 issues and pull requests.) -**We want Caddy 2 to be the web server of the Go community!** We are looking for maintainers to represent the community. Please become involved (issues, PRs, [our forum](https://caddy.community) etc.) and express interest if you are committed to being a collaborator on the Caddy project. +**Caddy 2 is the web server of the Go community.** We are looking for maintainers to represent the community! Please become involved (issues, PRs, [our forum](https://caddy.community) etc.) and express interest if you are committed to being a collaborator on the Caddy project. ### Menu @@ -121,7 +121,7 @@ Caddy 2 can be configured with a Caddyfile, much like in v1, for example: example.com templates -encode gzip zstd +encode gzip zstd try_files {path}.html {path} reverse_proxy /api localhost:9005 file_server @@ -173,14 +173,14 @@ Note that breaking changes are expected until the stable 2.0 release. ## List of Improvements -The following is a non-comprehensive list of significant improvements over Caddy 1. Not everything in this list is finished yet, but they will be finished or at least will be possible with Caddy 2 or Caddy Enterprise: +The following is a non-comprehensive list of significant improvements over Caddy 1. Not everything in this list is finished yet, but they will be finished or at least will be possible with Caddy 2: - Centralized configuration. No more disparate use of environment variables, config files (potentially multiple!), CLI flags, etc. - REST API. Control Caddy with HTTP requests to an administration endpoint. Changes are applied immediately and efficiently. - Dynamic configuration. Any and all specific config values can be modified directly through the admin API with a REST endpoint. - - Enterprise: Change only specific configuration settings instead of needing to specify the whole config each time. This makes it safe and easy to change Caddy's config with manually-crafted curl commands, for example. + - Change only specific configuration settings instead of needing to specify the whole config each time. This makes it safe and easy to change Caddy's config with manually-crafted curl commands, for example. - No configuration files. Except optionally to bootstrap its configuration at startup. You can still use config files if you wish, and we expect that most people will. -- Enterprise: Export the current Caddy configuration with an API GET request. +- Export the current Caddy configuration with an API GET request. - Silky-smooth graceful reloads. Update the configuration up to dozens of times per second with no dropped requests and very little memory cost. Our unique graceful reload technology is lighter and faster **and works on all platforms, including Windows**. - An embedded scripting language! Caddy2 has native Starlark integration. Do things you never thought possible with higher performance than Lua, JavaScript, and other VMs. Starlark is expressive, familiar (dialect of Python), _almost_ Turing-complete, and highly efficient. (We're still improving performance here.) - Using [XDG standards](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html#variables) instead of dumping all assets in `$HOME/.caddy`. @@ -196,9 +196,9 @@ The following is a non-comprehensive list of significant improvements over Caddy - Automation policy doesn't have to be limited to just ACME - could be any way to manage certificates - Fine-grained control over TLS handshakes - If an ACME challenge fails, other enabled challenges will be tried (no other web server does this) - - Enterprise: TLS Session Ticket Ephemeral Keys (STEKs) can be rotated in a cluster for increased performance (no other web server does this either!) - - Enterprise: Ability to select a specific certificate per ClientHello given multiple qualifying certificates - - Enterprise: Provide TLS certificates without persisting them to disk; keep private keys entirely in memory + - TLS Session Ticket Ephemeral Keys (STEKs) can be rotated in a cluster for increased performance (no other web server does this either!) + - Ability to select a specific certificate per ClientHello given multiple qualifying certificates + - Provide TLS certificates without persisting them to disk; keep private keys entirely in memory - All-new HTTP server core - Listeners can be configured for any network type, address, and port range - Customizable TLS connection policies @@ -219,7 +219,7 @@ The following is a non-comprehensive list of significant improvements over Caddy - Highly descriptive/traceable errors - Very flexible error handling, with the ability to specify a whole list of routes just for error cases - More control over automatic HTTPS: disable entirely, disable only HTTP->HTTPS redirects, disable only cert management, and for certain names, etc. - - Enterprise: Use Starlark to build custom, dynamic HTTP handlers at request-time + - Use Starlark to build custom, dynamic HTTP handlers at request-time - We are finding that -- on average -- Caddy 2's Starlark handlers are ~1.25-2x faster than NGINX+Lua. And a few major features still being worked on: @@ -336,28 +336,10 @@ Starlark performs at least as well as NGINX+Lua (more performance tests ongoing, In summary: Caddy 2 config is declarative, but can be imperative where that is useful. -### What will Caddy 2 be licensed as? +### What is Caddy 2 licensed as? Caddy 2 is licensed under the Apache 2.0 open source license. There are no official Caddy 2 distributions that are proprietary. -### What is Caddy Enterprise? - -Caddy Enterprise is a collection of plugins for Caddy 2 which provide features and performance that are crucial in business settings. Caddy Enterprise is not a separate web server and does not even use a separate code base from Caddy 2; it is not even a separate branch that merges the open source core in every once in a while. In other words, open source users aren't missing out on a "better" web server, but Enterprise provides features that are used by businesses. - -Caddy Enterprise is for businesses that need more advanced features for higher scalability and easier management of clusters. It includes: - -- a web UI -- performance improvements within a cluster -- advanced TLS controls -- fine-grained config changes (i.e. ability to change only certain parts of the configuration) -- training and support -- advanced HTTP handlers for authentication, metrics, debugging, and more -- dynamic HTTP handlers and TLS handshakes with Starlark - -Caddy Enterprise can be customized for each customer according to their needs. - -Caddy 2 and Caddy Enterprise offer equal levels of security and, as mentioned, share the same open source code base. - ### Does Caddy 2 have telemetry? No. There was not enough academic interest to continue supporting it. If telemetry does get added later, it will not be on by default or will be vastly reduced in its scope. @@ -368,7 +350,7 @@ Yes. HTTPS is automatic and enabled by default when possible, just like in Caddy ## How do I avoid Let's Encrypt rate limits with Caddy 2? -As you are testing and developing with Caddy 2, you may wish to use test ("staging") certificates from Let's Encrypt to avoid rate limits. By default, Caddy 2 uses Let's Encrypt's production endpoint to get real certificates for your domains, but their [rate limits](https://letsencrypt.org/docs/rate-limits/) forbid testing and development use of this endpoint for good reasons. You can switch to their [staging endpoint](https://letsencrypt.org/docs/staging-environment/) by adding the staging CA to your automation policy in the `tls` app: +As you are testing and developing with Caddy 2, you should use test ("staging") certificates from Let's Encrypt to avoid rate limits. By default, Caddy 2 uses Let's Encrypt's production endpoint to get real certificates for your domains, but their [rate limits](https://letsencrypt.org/docs/rate-limits/) forbid testing and development use of this endpoint for good reasons. You can switch to their [staging endpoint](https://letsencrypt.org/docs/staging-environment/) by adding the staging CA to your automation policy in the `tls` app: ```json "tls": { @@ -385,14 +367,14 @@ As you are testing and developing with Caddy 2, you may wish to use test ("stagi } ``` -Or with the Caddyfile: +Or with the Caddyfile, using a global options block at the top: ``` -tls { - ca https://acme-staging-v02.api.letsencrypt.org/directory +{ + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory } ``` ## Can we get some access controls on the admin endpoint? -Yeah, that's coming. For now, you can use a unix socket that is properly permissioned for some basic security. +Yeah, that's coming. For now, you can use a permissioned unix socket for some basic security. -- cgit v1.2.3 From 26cc8837084f9cea6057e9908f0b5bde0eb15d3e Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 10 Oct 2019 11:02:16 -0600 Subject: http: Add Starlark handler This migrates a feature that was previously reserved for enterprise users, according to #2786. The Starlark integration needs to be updated since this was made before some significant changes in the v2 code base. When functional, it makes it possible to have very dynamic HTTP handlers. This will be a long-term ongoing project. Credit to Danny Navarro --- cmd/caddy/main.go | 1 + modules/caddyhttp/starlarkmw/example/caddy.json | 19 +++ .../caddyhttp/starlarkmw/internal/lib/module.go | 165 ++++++++++++++++++++ modules/caddyhttp/starlarkmw/starlarkmw.go | 172 +++++++++++++++++++++ .../caddyhttp/starlarkmw/tools/gen/example.star | 40 +++++ 5 files changed, 397 insertions(+) create mode 100644 modules/caddyhttp/starlarkmw/example/caddy.json create mode 100644 modules/caddyhttp/starlarkmw/internal/lib/module.go create mode 100644 modules/caddyhttp/starlarkmw/starlarkmw.go create mode 100644 modules/caddyhttp/starlarkmw/tools/gen/example.star diff --git a/cmd/caddy/main.go b/cmd/caddy/main.go index 87226cd..3906eae 100644 --- a/cmd/caddy/main.go +++ b/cmd/caddy/main.go @@ -34,6 +34,7 @@ import ( _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy/fastcgi" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" + _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/starlarkmw" _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates" _ "github.com/caddyserver/caddy/v2/modules/caddytls" _ "github.com/caddyserver/caddy/v2/modules/caddytls/standardstek" diff --git a/modules/caddyhttp/starlarkmw/example/caddy.json b/modules/caddyhttp/starlarkmw/example/caddy.json new file mode 100644 index 0000000..66f9f2c --- /dev/null +++ b/modules/caddyhttp/starlarkmw/example/caddy.json @@ -0,0 +1,19 @@ +{ + "apps": { + "http": { + "servers": { + "MY_SERVER": { + "listen": [":3001"], + "routes": [ + { + "handle": { + "handler": "starlark", + "script": "def setup(r):\n\t# create some middlewares specific to this request\n\ttemplates = loadModule('http.handlers.templates', {'include_root': './includes'})\n\tmidChain = execute([templates])\n\ndef serveHTTP (rw, r):\n\trw.Write('Hello world, from Starlark!')\n" + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/modules/caddyhttp/starlarkmw/internal/lib/module.go b/modules/caddyhttp/starlarkmw/internal/lib/module.go new file mode 100644 index 0000000..a7164cd --- /dev/null +++ b/modules/caddyhttp/starlarkmw/internal/lib/module.go @@ -0,0 +1,165 @@ +package lib + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + + "github.com/caddyserver/caddy/v2" + "go.starlark.net/starlark" +) + +// ResponderModule represents a module that satisfies the caddyhttp handler. +type ResponderModule struct { + Name string + Cfg json.RawMessage + Instance caddyhttp.Handler +} + +func (r ResponderModule) Freeze() {} +func (r ResponderModule) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: responder module") } +func (r ResponderModule) String() string { return "responder module" } +func (r ResponderModule) Type() string { return "responder module" } +func (r ResponderModule) Truth() starlark.Bool { return true } + +// Middleware represents a module that satisfies the starlark Value interface. +type Middleware struct { + Name string + Cfg json.RawMessage + Instance caddyhttp.MiddlewareHandler +} + +func (r Middleware) Freeze() {} +func (r Middleware) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: middleware") } +func (r Middleware) String() string { return "middleware" } +func (r Middleware) Type() string { return "middleware" } +func (r Middleware) Truth() starlark.Bool { return true } + +// LoadMiddleware represents the method exposed to starlark to load a Caddy module. +type LoadMiddleware struct { + Middleware Middleware + Ctx caddy.Context +} + +func (r LoadMiddleware) Freeze() {} +func (r LoadMiddleware) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: loadMiddleware") } +func (r LoadMiddleware) String() string { return "loadMiddleware" } +func (r LoadMiddleware) Type() string { return "function: loadMiddleware" } +func (r LoadMiddleware) Truth() starlark.Bool { return true } + +// Run is the method bound to the starlark loadMiddleware function. +func (r *LoadMiddleware) Run(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var name string + var cfg *starlark.Dict + err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 2, &name, &cfg) + if err != nil { + return starlark.None, fmt.Errorf("unpacking arguments: %v", err.Error()) + } + + js := json.RawMessage(cfg.String()) + + if strings.Index(name, "http.handlers.") == -1 { + name = fmt.Sprintf("http.handlers.%s", name) + } + + inst, err := r.Ctx.LoadModule(name, js) + if err != nil { + return starlark.None, err + } + + mid, ok := inst.(caddyhttp.MiddlewareHandler) + if !ok { + return starlark.None, fmt.Errorf("could not assert as middleware handler") + } + + m := Middleware{ + Name: name, + Cfg: js, + Instance: mid, + } + + r.Middleware = m + + return m, nil +} + +// LoadResponder represents the method exposed to starlark to load a Caddy middleware responder. +type LoadResponder struct { + Module ResponderModule + Ctx caddy.Context +} + +func (r LoadResponder) Freeze() {} +func (r LoadResponder) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: loadModule") } +func (r LoadResponder) String() string { return "loadModule" } +func (r LoadResponder) Type() string { return "function: loadModule" } +func (r LoadResponder) Truth() starlark.Bool { return true } + +// Run is the method bound to the starlark loadResponder function. +func (r *LoadResponder) Run(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var name string + var cfg *starlark.Dict + err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 2, &name, &cfg) + if err != nil { + return starlark.None, fmt.Errorf("unpacking arguments: %v", err.Error()) + } + + js := json.RawMessage(cfg.String()) + + if strings.Index(name, "http.handlers.") == -1 { + name = fmt.Sprintf("http.handlers.%s", name) + } + + inst, err := r.Ctx.LoadModule(name, js) + if err != nil { + return starlark.None, err + } + + res, ok := inst.(caddyhttp.Handler) + if !ok { + return starlark.None, fmt.Errorf("could not assert as responder") + } + + m := ResponderModule{ + Name: name, + Cfg: js, + Instance: res, + } + + r.Module = m + + return m, nil +} + +// Execute represents the method exposed to starlark to build a middleware chain. +type Execute struct { + Modules []Middleware +} + +func (r Execute) Freeze() {} +func (r Execute) Hash() (uint32, error) { return 0, fmt.Errorf("unhashable: execute") } +func (r Execute) String() string { return "execute" } +func (r Execute) Type() string { return "function: execute" } +func (r Execute) Truth() starlark.Bool { return true } + +// Run is the method bound to the starlark execute function. +func (r *Execute) Run(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var mids *starlark.List + err := starlark.UnpackPositionalArgs(fn.Name(), args, kwargs, 1, &mids) + if err != nil { + return starlark.None, fmt.Errorf("unpacking arguments: %v", err.Error()) + } + + for i := 0; i < mids.Len(); i++ { + val, ok := mids.Index(i).(Middleware) + if !ok { + return starlark.None, fmt.Errorf("cannot get module from execute") + } + + r.Modules = append(r.Modules, val) + } + + return starlark.None, nil +} diff --git a/modules/caddyhttp/starlarkmw/starlarkmw.go b/modules/caddyhttp/starlarkmw/starlarkmw.go new file mode 100644 index 0000000..007ddb4 --- /dev/null +++ b/modules/caddyhttp/starlarkmw/starlarkmw.go @@ -0,0 +1,172 @@ +package starlarkmw + +import ( + "context" + "fmt" + "net/http" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + caddyscript "github.com/caddyserver/caddy/v2/pkg/caddyscript/lib" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/starlarkmw/internal/lib" + "github.com/starlight-go/starlight/convert" + "go.starlark.net/starlark" +) + +func init() { + caddy.RegisterModule(StarlarkMW{}) +} + +// StarlarkMW represents a middleware responder written in starlark +type StarlarkMW struct { + Script string `json:"script"` + serveHTTP *starlark.Function + setup *starlark.Function + thread *starlark.Thread + loadMiddleware *lib.LoadMiddleware + execute *lib.Execute + globals *starlark.StringDict + gctx caddy.Context + rctx caddy.Context + rcancel context.CancelFunc +} + +// CaddyModule returns the Caddy module information. +func (StarlarkMW) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.starlark", + New: func() caddy.Module { return new(StarlarkMW) }, + } +} + +// ServeHTTP responds to an http request with starlark. +func (s *StarlarkMW) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + var mwcancel context.CancelFunc + var mwctx caddy.Context + + // call setup() to prepare the middleware chain if it is defined + if s.setup != nil { + mwctx, mwcancel = caddy.NewContext(s.gctx) + defer mwcancel() + + s.loadMiddleware.Ctx = mwctx + args := starlark.Tuple{caddyscript.HTTPRequest{Req: r}} + + _, err := starlark.Call(new(starlark.Thread), s.setup, args, nil) + if err != nil { + return fmt.Errorf("starlark setup(), %v", err) + } + } + + // dynamically build middleware chain for each request + stack := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + wr, err := convert.ToValue(w) + if err != nil { + return fmt.Errorf("cannot convert response writer to starlark value") + } + + args := starlark.Tuple{wr, caddyscript.HTTPRequest{Req: r}} + v, err := starlark.Call(new(starlark.Thread), s.serveHTTP, args, nil) + if err != nil { + return fmt.Errorf("starlark serveHTTP(), %v", err) + } + + // if a responder type was returned from starlark we should run it otherwise it + // is expected to handle the request + if resp, ok := v.(lib.ResponderModule); ok { + return resp.Instance.ServeHTTP(w, r) + } + + return nil + }) + + // TODO :- make middlewareResponseWriter exported and wrap w with that + var mid []caddyhttp.Middleware + for _, m := range s.execute.Modules { + mid = append(mid, func(next caddyhttp.HandlerFunc) caddyhttp.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + return m.Instance.ServeHTTP(w, r, next) + } + }) + } + + for i := len(mid) - 1; i >= 0; i-- { + stack = mid[i](stack) + } + + s.execute.Modules = nil + + return stack(w, r) +} + +// Cleanup cleans up any modules loaded during the creation of a starlark route. +func (s *StarlarkMW) Cleanup() error { + s.rcancel() + return nil +} + +// Provision sets up the starlark env. +func (s *StarlarkMW) Provision(ctx caddy.Context) error { + // store global context + s.gctx = ctx + + // setup context for cleaning up any modules loaded during starlark script parsing phase + rctx, cancel := caddy.NewContext(ctx) + s.rcancel = cancel + + // setup starlark global env + env := make(starlark.StringDict) + loadMiddleware := lib.LoadMiddleware{} + loadResponder := lib.LoadResponder{ + Ctx: rctx, + } + execute := lib.Execute{} + + lr := starlark.NewBuiltin("loadResponder", loadResponder.Run) + lr = lr.BindReceiver(&loadResponder) + env["loadResponder"] = lr + + lm := starlark.NewBuiltin("loadMiddleware", loadMiddleware.Run) + lm = lm.BindReceiver(&loadMiddleware) + env["loadMiddleware"] = lm + + ex := starlark.NewBuiltin("execute", execute.Run) + ex = ex.BindReceiver(&execute) + env["execute"] = ex + + // import caddyscript lib + env["time"] = caddyscript.Time{} + env["regexp"] = caddyscript.Regexp{} + + // configure starlark + thread := new(starlark.Thread) + s.thread = thread + + // run starlark script + globals, err := starlark.ExecFile(thread, "", s.Script, env) + if err != nil { + return fmt.Errorf("starlark exec file: %v", err.Error()) + } + + // extract defined methods to setup middleware chain and responder, setup is not required + var setup *starlark.Function + if _, ok := globals["setup"]; ok { + setup, ok = globals["setup"].(*starlark.Function) + if !ok { + return fmt.Errorf("setup function not defined in starlark script") + } + } + + serveHTTP, ok := globals["serveHTTP"].(*starlark.Function) + if !ok { + return fmt.Errorf("serveHTTP function not defined in starlark script") + } + + s.setup = setup + s.serveHTTP = serveHTTP + s.loadMiddleware = &loadMiddleware + s.execute = &execute + s.globals = &globals + + return nil +} diff --git a/modules/caddyhttp/starlarkmw/tools/gen/example.star b/modules/caddyhttp/starlarkmw/tools/gen/example.star new file mode 100644 index 0000000..6ccab32 --- /dev/null +++ b/modules/caddyhttp/starlarkmw/tools/gen/example.star @@ -0,0 +1,40 @@ +# any module that provisions resources +proxyConfig = { + 'load_balance_type': 'round_robin', + 'upstreams': [ + { + 'host': 'http://localhost:8080', + 'circuit_breaker': { + 'type': 'status_ratio', + 'threshold': 0.5 + } + }, + { + 'host': 'http://localhost:8081' + } + ] +} + +sfConfig = { + 'root': '/Users/dev/Desktop', + 'browse': {}, +} + +proxy = loadResponder('reverse_proxy', proxyConfig) +static_files = loadResponder('file_server', sfConfig) + +def setup(r): + # create some middlewares specific to this request + mid = [] + + if r.query.get('log') == 'true': + logMid = loadMiddleware('log', {'file': 'access.log'}) + mid.append(logMid) + + execute(mid) + +def serveHTTP(w, r): + if r.url.find('static') > 0: + return static_files + + return proxy -- cgit v1.2.3