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
}