@marcel91200 / caddy Mirror · read-only / commits / f259ed52
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 13, 16:45 UTC

admin: support ETag on config endpoints (#4579)

* admin: support ETags * support etags Co-authored-by: Matt Holt <mholt@users.noreply.github.com>

jhwz committed Jul 7, 2022 at 07:50 UTC f259ed52bb3764ce4fd5d88f1712cb43247c2639
3 files changed +105 -7
admin.go
+20 -3
@@ -21,10 +21,13 @@ import (
21 "crypto/tls"
22 "crypto/x509"
23 "encoding/base64"
24 + "encoding/hex"
25 "encoding/json"
26 "errors"
27 "expvar"
28 "fmt"
29 + "hash"
30 + "hash/fnv"
31 "io"
32 "net"
33 "net/http"
@@ -894,16 +897,30 @@ func (h adminHandler) originAllowed(origin *url.URL) bool {
897 return false
898 }
899
900 +// etagHasher returns a the hasher we used on the config to both
901 +// produce and verify ETags.
902 +func etagHasher() hash.Hash32 { return fnv.New32a() }
903 +
904 func handleConfig(w http.ResponseWriter, r *http.Request) error {
905 switch r.Method {
906 case http.MethodGet:
907 w.Header().Set("Content-Type", "application/json")
901 -
902 - err := readConfig(r.URL.Path, w)
908 + // Set the ETag as a trailer header.
909 + // The alternative is to write the config to a buffer, and
910 + // then hash that.
911 + w.Header().Set("Trailer", "ETag")
912 +
913 + hash := etagHasher()
914 + configWriter := io.MultiWriter(w, hash)
915 + err := readConfig(r.URL.Path, configWriter)
916 if err != nil {
917 return APIError{HTTPStatus: http.StatusBadRequest, Err: err}
918 }
919
920 + // we could consider setting up a sync.Pool for the summed
921 + // hashes to reduce GC pressure.
922 + w.Header().Set("ETag", r.URL.Path+" "+hex.EncodeToString(hash.Sum(nil)))
923 +
924 return nil
925
926 case http.MethodPost,
@@ -937,7 +954,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
954
955 forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
956
940 - err := changeConfig(r.Method, r.URL.Path, body, forceReload)
957 + err := changeConfig(r.Method, r.URL.Path, body, r.Header.Get("If-Match"), forceReload)
958 if err != nil && !errors.Is(err, errSameConfig) {
959 return err
960 }
admin_test.go
+50 -1
@@ -15,7 +15,9 @@
15 package caddy
16
17 import (
18 + "encoding/hex"
19 "encoding/json"
20 + "net/http"
21 "reflect"
22 "sync"
23 "testing"
@@ -139,10 +141,57 @@ func TestLoadConcurrent(t *testing.T) {
141 wg.Done()
142 }()
143 }
142 -
144 wg.Wait()
145 }
146
147 +type fooModule struct {
148 + IntField int
149 + StrField string
150 +}
151 +
152 +func (fooModule) CaddyModule() ModuleInfo {
153 + return ModuleInfo{
154 + ID: "foo",
155 + New: func() Module { return new(fooModule) },
156 + }
157 +}
158 +func (fooModule) Start() error { return nil }
159 +func (fooModule) Stop() error { return nil }
160 +
161 +func TestETags(t *testing.T) {
162 + RegisterModule(fooModule{})
163 +
164 + if err := Load([]byte(`{"apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil {
165 + t.Fatalf("loading: %s", err)
166 + }
167 +
168 + const key = "/" + rawConfigKey + "/apps/foo"
169 +
170 + // try update the config with the wrong etag
171 + err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), "/"+rawConfigKey+" not_an_etag", false)
172 + if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
173 + t.Fatalf("expected precondition failed; got %v", err)
174 + }
175 +
176 + // get the etag
177 + hash := etagHasher()
178 + if err := readConfig(key, hash); err != nil {
179 + t.Fatalf("reading: %s", err)
180 + }
181 +
182 + // do the same update with the correct key
183 + err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), key+" "+hex.EncodeToString(hash.Sum(nil)), false)
184 + if err != nil {
185 + t.Fatalf("expected update to work; got %v", err)
186 + }
187 +
188 + // now try another update. The hash should no longer match and we should get precondition failed
189 + err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), key+" "+hex.EncodeToString(hash.Sum(nil)), false)
190 + if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
191 + t.Fatalf("expected precondition failed; got %v", err)
192 + }
193 +}
194 +
195 func BenchmarkLoad(b *testing.B) {
196 for i := 0; i < b.N; i++ {
197 Load(testCfg, true)
caddy.go
+35 -3
@@ -17,6 +17,7 @@ package caddy
17 import (
18 "bytes"
19 "context"
20 + "encoding/hex"
21 "encoding/json"
22 "errors"
23 "fmt"
@@ -111,7 +112,7 @@ func Load(cfgJSON []byte, forceReload bool) error {
112 }
113 }()
114
114 - err := changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, forceReload)
115 + err := changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, "", forceReload)
116 if errors.Is(err, errSameConfig) {
117 err = nil // not really an error
118 }
@@ -125,7 +126,12 @@ func Load(cfgJSON []byte, forceReload bool) error {
126 // occur unless forceReload is true. If the config is unchanged and not
127 // forcefully reloaded, then errConfigUnchanged This function is safe for
128 // concurrent use.
128 -func changeConfig(method, path string, input []byte, forceReload bool) error {
129 +// The ifMatchHeader can optionally be given a string of the format:
130 +// "<path> <hash>"
131 +// where <path> is the absolute path in the config and <hash> is the expected hash of
132 +// the config at that path. If the hash in the ifMatchHeader doesn't match
133 +// the hash of the config, then an APIError with status 412 will be returned.
134 +func changeConfig(method, path string, input []byte, ifMatchHeader string, forceReload bool) error {
135 switch method {
136 case http.MethodGet,
137 http.MethodHead,
@@ -138,6 +144,32 @@ func changeConfig(method, path string, input []byte, forceReload bool) error {
144 currentCfgMu.Lock()
145 defer currentCfgMu.Unlock()
146
147 + if ifMatchHeader != "" {
148 + // read out the parts
149 + parts := strings.Fields(ifMatchHeader)
150 + if len(parts) != 2 {
151 + return APIError{
152 + HTTPStatus: http.StatusBadRequest,
153 + Err: fmt.Errorf("malformed If-Match header; expect format \"<path> <hash>\""),
154 + }
155 + }
156 +
157 + // get the current hash of the config
158 + // at the given path
159 + hash := etagHasher()
160 + err := unsyncedConfigAccess(http.MethodGet, parts[0], nil, hash)
161 + if err != nil {
162 + return err
163 + }
164 +
165 + if hex.EncodeToString(hash.Sum(nil)) != parts[1] {
166 + return APIError{
167 + HTTPStatus: http.StatusPreconditionFailed,
168 + Err: fmt.Errorf("If-Match header did not match current config hash"),
169 + }
170 + }
171 + }
172 +
173 err := unsyncedConfigAccess(method, path, input, nil)
174 if err != nil {
175 return err
@@ -500,7 +532,7 @@ func finishSettingUp(ctx Context, cfg *Config) error {
532
533 runLoadedConfig := func(config []byte) error {
534 logger.Info("applying dynamically-loaded config")
503 - err := changeConfig(http.MethodPost, "/"+rawConfigKey, config, false)
535 + err := changeConfig(http.MethodPost, "/"+rawConfigKey, config, "", false)
536 if errors.Is(err, errSameConfig) {
537 return err
538 }