mirror of
https://github.com/caddyserver/caddy.git
synced 2024-12-16 21:56:40 -05:00
admin: support ETag on config endpoints (#4579)
* admin: support ETags * support etags Co-authored-by: Matt Holt <mholt@users.noreply.github.com>
This commit is contained in:
parent
8bac134f26
commit
f259ed52bb
3 changed files with 104 additions and 6 deletions
21
admin.go
21
admin.go
|
@ -21,10 +21,13 @@ import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"expvar"
|
"expvar"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"hash/fnv"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -894,16 +897,30 @@ func (h adminHandler) originAllowed(origin *url.URL) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// etagHasher returns a the hasher we used on the config to both
|
||||||
|
// produce and verify ETags.
|
||||||
|
func etagHasher() hash.Hash32 { return fnv.New32a() }
|
||||||
|
|
||||||
func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// Set the ETag as a trailer header.
|
||||||
|
// The alternative is to write the config to a buffer, and
|
||||||
|
// then hash that.
|
||||||
|
w.Header().Set("Trailer", "ETag")
|
||||||
|
|
||||||
err := readConfig(r.URL.Path, w)
|
hash := etagHasher()
|
||||||
|
configWriter := io.MultiWriter(w, hash)
|
||||||
|
err := readConfig(r.URL.Path, configWriter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return APIError{HTTPStatus: http.StatusBadRequest, Err: err}
|
return APIError{HTTPStatus: http.StatusBadRequest, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we could consider setting up a sync.Pool for the summed
|
||||||
|
// hashes to reduce GC pressure.
|
||||||
|
w.Header().Set("ETag", r.URL.Path+" "+hex.EncodeToString(hash.Sum(nil)))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case http.MethodPost,
|
case http.MethodPost,
|
||||||
|
@ -937,7 +954,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
|
forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
|
||||||
|
|
||||||
err := changeConfig(r.Method, r.URL.Path, body, forceReload)
|
err := changeConfig(r.Method, r.URL.Path, body, r.Header.Get("If-Match"), forceReload)
|
||||||
if err != nil && !errors.Is(err, errSameConfig) {
|
if err != nil && !errors.Is(err, errSameConfig) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,9 @@
|
||||||
package caddy
|
package caddy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -139,10 +141,57 @@ func TestLoadConcurrent(t *testing.T) {
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fooModule struct {
|
||||||
|
IntField int
|
||||||
|
StrField string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fooModule) CaddyModule() ModuleInfo {
|
||||||
|
return ModuleInfo{
|
||||||
|
ID: "foo",
|
||||||
|
New: func() Module { return new(fooModule) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (fooModule) Start() error { return nil }
|
||||||
|
func (fooModule) Stop() error { return nil }
|
||||||
|
|
||||||
|
func TestETags(t *testing.T) {
|
||||||
|
RegisterModule(fooModule{})
|
||||||
|
|
||||||
|
if err := Load([]byte(`{"apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil {
|
||||||
|
t.Fatalf("loading: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = "/" + rawConfigKey + "/apps/foo"
|
||||||
|
|
||||||
|
// try update the config with the wrong etag
|
||||||
|
err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), "/"+rawConfigKey+" not_an_etag", false)
|
||||||
|
if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
|
||||||
|
t.Fatalf("expected precondition failed; got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the etag
|
||||||
|
hash := etagHasher()
|
||||||
|
if err := readConfig(key, hash); err != nil {
|
||||||
|
t.Fatalf("reading: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// do the same update with the correct key
|
||||||
|
err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), key+" "+hex.EncodeToString(hash.Sum(nil)), false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected update to work; got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// now try another update. The hash should no longer match and we should get precondition failed
|
||||||
|
err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), key+" "+hex.EncodeToString(hash.Sum(nil)), false)
|
||||||
|
if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
|
||||||
|
t.Fatalf("expected precondition failed; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func BenchmarkLoad(b *testing.B) {
|
func BenchmarkLoad(b *testing.B) {
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
Load(testCfg, true)
|
Load(testCfg, true)
|
||||||
|
|
38
caddy.go
38
caddy.go
|
@ -17,6 +17,7 @@ package caddy
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -111,7 +112,7 @@ func Load(cfgJSON []byte, forceReload bool) error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err := changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, forceReload)
|
err := changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, "", forceReload)
|
||||||
if errors.Is(err, errSameConfig) {
|
if errors.Is(err, errSameConfig) {
|
||||||
err = nil // not really an error
|
err = nil // not really an error
|
||||||
}
|
}
|
||||||
|
@ -125,7 +126,12 @@ func Load(cfgJSON []byte, forceReload bool) error {
|
||||||
// occur unless forceReload is true. If the config is unchanged and not
|
// occur unless forceReload is true. If the config is unchanged and not
|
||||||
// forcefully reloaded, then errConfigUnchanged This function is safe for
|
// forcefully reloaded, then errConfigUnchanged This function is safe for
|
||||||
// concurrent use.
|
// concurrent use.
|
||||||
func changeConfig(method, path string, input []byte, forceReload bool) error {
|
// The ifMatchHeader can optionally be given a string of the format:
|
||||||
|
// "<path> <hash>"
|
||||||
|
// where <path> is the absolute path in the config and <hash> is the expected hash of
|
||||||
|
// the config at that path. If the hash in the ifMatchHeader doesn't match
|
||||||
|
// the hash of the config, then an APIError with status 412 will be returned.
|
||||||
|
func changeConfig(method, path string, input []byte, ifMatchHeader string, forceReload bool) error {
|
||||||
switch method {
|
switch method {
|
||||||
case http.MethodGet,
|
case http.MethodGet,
|
||||||
http.MethodHead,
|
http.MethodHead,
|
||||||
|
@ -138,6 +144,32 @@ func changeConfig(method, path string, input []byte, forceReload bool) error {
|
||||||
currentCfgMu.Lock()
|
currentCfgMu.Lock()
|
||||||
defer currentCfgMu.Unlock()
|
defer currentCfgMu.Unlock()
|
||||||
|
|
||||||
|
if ifMatchHeader != "" {
|
||||||
|
// read out the parts
|
||||||
|
parts := strings.Fields(ifMatchHeader)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return APIError{
|
||||||
|
HTTPStatus: http.StatusBadRequest,
|
||||||
|
Err: fmt.Errorf("malformed If-Match header; expect format \"<path> <hash>\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the current hash of the config
|
||||||
|
// at the given path
|
||||||
|
hash := etagHasher()
|
||||||
|
err := unsyncedConfigAccess(http.MethodGet, parts[0], nil, hash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hex.EncodeToString(hash.Sum(nil)) != parts[1] {
|
||||||
|
return APIError{
|
||||||
|
HTTPStatus: http.StatusPreconditionFailed,
|
||||||
|
Err: fmt.Errorf("If-Match header did not match current config hash"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := unsyncedConfigAccess(method, path, input, nil)
|
err := unsyncedConfigAccess(method, path, input, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -500,7 +532,7 @@ func finishSettingUp(ctx Context, cfg *Config) error {
|
||||||
|
|
||||||
runLoadedConfig := func(config []byte) error {
|
runLoadedConfig := func(config []byte) error {
|
||||||
logger.Info("applying dynamically-loaded config")
|
logger.Info("applying dynamically-loaded config")
|
||||||
err := changeConfig(http.MethodPost, "/"+rawConfigKey, config, false)
|
err := changeConfig(http.MethodPost, "/"+rawConfigKey, config, "", false)
|
||||||
if errors.Is(err, errSameConfig) {
|
if errors.Is(err, errSameConfig) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue