summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--admin.go799
-rw-r--r--admin_test.go90
-rw-r--r--caddy.go247
-rw-r--r--cmd/commandfuncs.go19
-rw-r--r--dynamicconfig.go358
-rw-r--r--dynamicconfig_test.go123
-rw-r--r--go.mod3
-rw-r--r--go.sum4
8 files changed, 961 insertions, 682 deletions
diff --git a/admin.go b/admin.go
index e48a4ca..fb7b34b 100644
--- a/admin.go
+++ b/admin.go
@@ -18,157 +18,202 @@ import (
"bytes"
"context"
"encoding/json"
- "errors"
+ "expvar"
"fmt"
"io"
- "io/ioutil"
- "log"
"mime"
- "net"
"net/http"
"net/http/pprof"
+ "net/url"
"os"
+ "path"
+ "strconv"
"strings"
"sync"
"time"
"github.com/caddyserver/caddy/v2/caddyconfig"
- "github.com/mholt/certmagic"
- "github.com/rs/cors"
"go.uber.org/zap"
)
-var (
- cfgEndptSrv *http.Server
- cfgEndptSrvMu sync.Mutex
-)
-
-var ErrAdminInterfaceNotConfigured = errors.New("no admin configuration has been set")
+// TODO: is there a way to make the admin endpoint so that it can be plugged into the HTTP app? see issue #2833
// AdminConfig configures the admin endpoint.
type AdminConfig struct {
- Listen string `json:"listen,omitempty"`
+ Disabled bool `json:"disabled,omitempty"`
+ Listen string `json:"listen,omitempty"`
+ EnforceOrigin bool `json:"enforce_origin,omitempty"`
+ Origins []string `json:"origins,omitempty"`
}
-// DefaultAdminConfig is the default configuration
-// for the administration endpoint.
-var DefaultAdminConfig = &AdminConfig{
- Listen: DefaultAdminListen,
+// listenAddr extracts a singular listen address from ac.Listen,
+// returning the network and the address of the listener.
+func (admin AdminConfig) listenAddr() (netw string, addr string, err error) {
+ var listenAddrs []string
+ input := admin.Listen
+ if input == "" {
+ input = DefaultAdminListen
+ }
+ netw, listenAddrs, err = ParseNetworkAddress(input)
+ if err != nil {
+ err = fmt.Errorf("parsing admin listener address: %v", err)
+ return
+ }
+ if len(listenAddrs) != 1 {
+ err = fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs)
+ return
+ }
+ addr = listenAddrs[0]
+ return
}
-// TODO: holy smokes, the admin endpoint might not have to live in caddy's core.
-
-// StartAdmin starts Caddy's administration endpoint,
-// bootstrapping it with an optional configuration
-// in the format of JSON bytes. It opens a listener
-// resource. When no longer needed, StopAdmin should
-// be called.
-// If no configuration is given, a default listener is
-// started. If a configuration is given that does NOT
-// specifically configure the admin interface,
-// `ErrAdminInterfaceNotConfigured` is returned and no
-// listener is initialized.
-func StartAdmin(initialConfigJSON []byte) error {
- cfgEndptSrvMu.Lock()
- defer cfgEndptSrvMu.Unlock()
-
- if cfgEndptSrv != nil {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- err := cfgEndptSrv.Shutdown(ctx)
- if err != nil {
- return fmt.Errorf("shutting down old admin endpoint: %v", err)
- }
+// newAdminHandler reads admin's config and returns an http.Handler suitable
+// for use in an admin endpoint server, which will be listening on listenAddr.
+func (admin AdminConfig) newAdminHandler(listenAddr string) adminHandler {
+ muxWrap := adminHandler{
+ enforceOrigin: admin.EnforceOrigin,
+ allowedOrigins: admin.allowedOrigins(listenAddr),
+ mux: http.NewServeMux(),
}
- adminConfig := DefaultAdminConfig
- if len(initialConfigJSON) > 0 {
- var config *Config
- err := json.Unmarshal(initialConfigJSON, &config)
- if err != nil {
- return fmt.Errorf("unmarshaling bootstrap config: %v", err)
- }
- if config != nil {
- if config.Admin == nil {
- return ErrAdminInterfaceNotConfigured
- }
- adminConfig = config.Admin
+ // addRoute just calls muxWrap.mux.Handle after
+ // wrapping the handler with error handling
+ addRoute := func(pattern string, h AdminHandler) {
+ wrapper := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ err := h.ServeHTTP(w, r)
+ muxWrap.handleError(w, r, err)
+ })
+ muxWrap.mux.Handle(pattern, wrapper)
+ }
+
+ // register standard config control endpoints
+ addRoute("/load", AdminHandlerFunc(handleLoad))
+ addRoute("/"+rawConfigKey+"/", AdminHandlerFunc(handleConfig))
+ addRoute("/id/", AdminHandlerFunc(handleConfigID))
+ addRoute("/unload", AdminHandlerFunc(handleUnload))
+ addRoute("/stop", AdminHandlerFunc(handleStop))
+
+ // register debugging endpoints
+ muxWrap.mux.HandleFunc("/debug/pprof/", pprof.Index)
+ muxWrap.mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
+ muxWrap.mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
+ muxWrap.mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
+ muxWrap.mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
+ muxWrap.mux.Handle("/debug/vars", expvar.Handler())
+
+ // register third-party module endpoints
+ for _, m := range GetModules("admin.api") {
+ router := m.New().(AdminRouter)
+ for _, route := range router.Routes() {
+ addRoute(route.Pattern, route.Handler)
}
}
- // extract a singular listener address
- netw, listenAddrs, err := ParseNetworkAddress(adminConfig.Listen)
- if err != nil {
- return fmt.Errorf("parsing admin listener address: %v", err)
+ return muxWrap
+}
+
+// allowedOrigins returns a list of origins that are allowed.
+// If admin.Origins is nil (null), the provided listen address
+// will be used as the default origin. If admin.Origins is
+// empty, no origins will be allowed, effectively bricking the
+// endpoint, but whatever.
+func (admin AdminConfig) allowedOrigins(listen string) []string {
+ uniqueOrigins := make(map[string]struct{})
+ for _, o := range admin.Origins {
+ uniqueOrigins[o] = struct{}{}
}
- if len(listenAddrs) != 1 {
- return fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs)
+ if admin.Origins == nil {
+ uniqueOrigins[listen] = struct{}{}
}
- ln, err := net.Listen(netw, listenAddrs[0])
- if err != nil {
- return err
+ var allowed []string
+ for origin := range uniqueOrigins {
+ allowed = append(allowed, origin)
}
+ return allowed
+}
- mux := http.NewServeMux()
- mux.HandleFunc("/load", handleLoadConfig)
- mux.HandleFunc("/stop", handleStop)
+// replaceAdmin replaces the running admin server according
+// to the relevant configuration in cfg. If no configuration
+// for the admin endpoint exists in cfg, a default one is
+// used, so that there is always an admin server (unless it
+// is explicitly configured to be disabled).
+func replaceAdmin(cfg *Config) error {
+ // always be sure to close down the old admin endpoint
+ // as gracefully as possible, even if the new one is
+ // disabled -- careful to use reference to the current
+ // (old) admin endpoint since it will be different
+ // when the function returns
+ oldAdminServer := adminServer
+ defer func() {
+ // do the shutdown asynchronously so that any
+ // current API request gets a response; this
+ // goroutine may last a few seconds
+ if oldAdminServer != nil {
+ go func(oldAdminServer *http.Server) {
+ err := stopAdminServer(oldAdminServer)
+ if err != nil {
+ Log().Named("admin").Error("stopping current admin endpoint", zap.Error(err))
+ }
+ }(oldAdminServer)
+ }
+ }()
- ///// BEGIN PPROF STUFF (TODO: Temporary) /////
- mux.HandleFunc("/debug/pprof/", pprof.Index)
- mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
- mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
- mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
- mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
- ///// END PPROF STUFF //////
+ // always get a valid admin config
+ adminConfig := DefaultAdminConfig
+ if cfg != nil && cfg.Admin != nil {
+ adminConfig = cfg.Admin
+ }
- for _, m := range GetModules("admin.routers") {
- adminrtr := m.New().(AdminRouter)
- for _, route := range adminrtr.Routes() {
- mux.Handle(route.Pattern, route)
- }
+ // if new admin endpoint is to be disabled, we're done
+ if adminConfig.Disabled {
+ Log().Named("admin").Warn("admin endpoint disabled")
+ return nil
}
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // TODO: improve/organize this logging
- Log().Named("admin.request").Info("",
- zap.String("method", r.Method),
- zap.String("uri", r.RequestURI),
- zap.String("remote", r.RemoteAddr),
- )
- cors.Default().Handler(mux).ServeHTTP(w, r)
- })
+ // extract a singular listener address
+ netw, addr, err := adminConfig.listenAddr()
+ if err != nil {
+ return err
+ }
- cfgEndptSrv = &http.Server{
+ handler := adminConfig.newAdminHandler(addr)
+
+ ln, err := Listen(netw, addr)
+ if err != nil {
+ return err
+ }
+
+ adminServer = &http.Server{
Handler: handler,
- ReadTimeout: 5 * time.Second,
+ ReadTimeout: 10 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
- IdleTimeout: 5 * time.Second,
+ IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1024 * 64,
}
- go cfgEndptSrv.Serve(ln)
+ go adminServer.Serve(ln)
- Log().Named("admin").Info("Caddy 2 admin endpoint started.", zap.String("listenAddress", adminConfig.Listen))
+ Log().Named("admin").Info(
+ "admin endpoint started",
+ zap.String("address", addr),
+ zap.Bool("enforce_origin", adminConfig.EnforceOrigin),
+ zap.Strings("origins", handler.allowedOrigins),
+ )
return nil
}
-// StopAdmin stops the API endpoint.
-func StopAdmin() error {
- cfgEndptSrvMu.Lock()
- defer cfgEndptSrvMu.Unlock()
-
- if cfgEndptSrv == nil {
- return fmt.Errorf("no server")
+func stopAdminServer(srv *http.Server) error {
+ if srv == nil {
+ return fmt.Errorf("no admin server")
}
-
- err := cfgEndptSrv.Shutdown(context.Background()) // TODO
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ err := srv.Shutdown(ctx)
if err != nil {
- return fmt.Errorf("shutting down server: %v", err)
+ return fmt.Errorf("shutting down admin server: %v", err)
}
-
- cfgEndptSrv = nil
-
+ Log().Named("admin").Info("stopped previous server")
return nil
}
@@ -179,117 +224,557 @@ type AdminRouter interface {
// AdminRoute represents a route for the admin endpoint.
type AdminRoute struct {
- http.Handler
Pattern string
+ Handler AdminHandler
}
-func handleLoadConfig(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+type adminHandler struct {
+ enforceOrigin bool
+ allowedOrigins []string
+ mux *http.ServeMux
+}
+
+// ServeHTTP is the external entry point for API requests.
+// It will only be called once per request.
+func (h adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ Log().Named("admin.api").Info("received request",
+ zap.String("method", r.Method),
+ zap.String("uri", r.RequestURI),
+ zap.String("remote_addr", r.RemoteAddr),
+ zap.Reflect("headers", r.Header),
+ )
+ h.serveHTTP(w, r)
+}
+
+// serveHTTP is the internal entry point for API requests. It may
+// be called more than once per request, for example if a request
+// is rewritten (i.e. internal redirect).
+func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
+ if h.enforceOrigin {
+ // DNS rebinding mitigation
+ err := h.checkHost(r)
+ if err != nil {
+ h.handleError(w, r, err)
+ return
+ }
+
+ // cross-site mitigation
+ origin, err := h.checkOrigin(r)
+ if err != nil {
+ h.handleError(w, r, err)
+ return
+ }
+
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT, PATCH, DELETE")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Cache-Control")
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+
+ // TODO: authentication & authorization, if configured
+
+ h.mux.ServeHTTP(w, r)
+}
+
+func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err error) {
+ if err == nil {
return
}
+ if err == ErrInternalRedir {
+ h.serveHTTP(w, r)
+ return
+ }
+
+ apiErr, ok := err.(APIError)
+ if !ok {
+ apiErr = APIError{
+ Code: http.StatusInternalServerError,
+ Err: err,
+ }
+ }
+ if apiErr.Code == 0 {
+ apiErr.Code = http.StatusInternalServerError
+ }
+ if apiErr.Message == "" && apiErr.Err != nil {
+ apiErr.Message = apiErr.Err.Error()
+ }
+
+ Log().Named("admin.api").Error("request error",
+ zap.Error(err),
+ zap.Int("status_code", apiErr.Code),
+ )
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(apiErr.Code)
+ json.NewEncoder(w).Encode(apiErr)
+}
+
+// checkHost returns a handler that wraps next such that
+// it will only be called if the request's Host header matches
+// a trustworthy/expected value. This helps to mitigate DNS
+// rebinding attacks.
+func (h adminHandler) checkHost(r *http.Request) error {
+ var allowed bool
+ for _, allowedHost := range h.allowedOrigins {
+ if r.Host == allowedHost {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ return APIError{
+ Code: http.StatusForbidden,
+ Err: fmt.Errorf("host not allowed: %s", r.Host),
+ }
+ }
+ return nil
+}
+
+// checkOrigin ensures that the Origin header, if
+// set, matches the intended target; prevents arbitrary
+// sites from issuing requests to our listener. It
+// returns the origin that was obtained from r.
+func (h adminHandler) checkOrigin(r *http.Request) (string, error) {
+ origin := h.getOriginHost(r)
+ if origin == "" {
+ return origin, APIError{
+ Code: http.StatusForbidden,
+ Err: fmt.Errorf("missing required Origin header"),
+ }
+ }
+ if !h.originAllowed(origin) {
+ return origin, APIError{
+ Code: http.StatusForbidden,
+ Err: fmt.Errorf("client is not allowed to access from origin %s", origin),
+ }
+ }
+ return origin, nil
+}
+
+func (h adminHandler) getOriginHost(r *http.Request) string {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ origin = r.Header.Get("Referer")
+ }
+ originURL, err := url.Parse(origin)
+ if err == nil && originURL.Host != "" {
+ origin = originURL.Host
+ }
+ return origin
+}
+
+func (h adminHandler) originAllowed(origin string) bool {
+ for _, allowedOrigin := range h.allowedOrigins {
+ originCopy := origin
+ if !strings.Contains(allowedOrigin, "://") {
+ // no scheme specified, so allow both
+ originCopy = strings.TrimPrefix(originCopy, "http://")
+ originCopy = strings.TrimPrefix(originCopy, "https://")
+ }
+ if originCopy == allowedOrigin {
+ return true
+ }
+ }
+ return false
+}
+
+func handleLoad(w http.ResponseWriter, r *http.Request) error {
+ if r.Method != http.MethodPost {
+ return APIError{
+ Code: http.StatusMethodNotAllowed,
+ Err: fmt.Errorf("method not allowed"),
+ }
+ }
+
+ buf := bufPool.Get().(*bytes.Buffer)
+ buf.Reset()
+ defer bufPool.Put(buf)
+
+ _, err := io.Copy(buf, r.Body)
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("reading request body: %v", err),
+ }
+ }
+ body := buf.Bytes()
// 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 != "" {
ct, _, err := mime.ParseMediaType(ctHeader)
if err != nil {
- http.Error(w, "Invalid Content-Type: "+err.Error(), http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("invalid Content-Type: %v", err),
+ }
}
if !strings.HasSuffix(ct, "/json") {
slashIdx := strings.Index(ct, "/")
if slashIdx < 0 {
- http.Error(w, "Malformed Content-Type", http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("malformed Content-Type"),
+ }
}
adapterName := ct[slashIdx+1:]
cfgAdapter := caddyconfig.GetAdapter(adapterName)
if cfgAdapter == nil {
- http.Error(w, "Unrecognized config adapter: "+adapterName, http.StatusBadRequest)
- return
- }
- body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024))
- if err != nil {
- http.Error(w, "Error reading request body: "+err.Error(), http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("unrecognized config adapter '%s'", adapterName),
+ }
}
result, warnings, err := cfgAdapter.Adapt(body, nil)
if err != nil {
- log.Printf("[ADMIN][ERROR] adapting config from %s: %v", adapterName, err)
- http.Error(w, fmt.Sprintf("Adapting config from %s: %v", adapterName, err), http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("adapting config using %s adapter: %v", adapterName, err),
+ }
}
if len(warnings) > 0 {
respBody, err := json.Marshal(warnings)
if err != nil {
- log.Printf("[ADMIN][ERROR] marshaling warnings: %v", err)
+ Log().Named("admin.api.load").Error(err.Error())
}
w.Write(respBody)
}
- // replace original request body with adapted JSON
- r.Body.Close()
- r.Body = ioutil.NopCloser(bytes.NewReader(result))
+ body = result
+ }
+ }
+
+ forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
+
+ err = Load(body, forceReload)
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("loading config: %v", err),
+ }
+ }
+
+ Log().Named("admin.api").Info("load complete")
+
+ return nil
+}
+
+func handleConfig(w http.ResponseWriter, r *http.Request) error {
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("Content-Type", "application/json")
+
+ err := readConfig(r.URL.Path, w)
+ if err != nil {
+ return APIError{Code: http.StatusBadRequest, Err: err}
+ }
+
+ return nil
+
+ case http.MethodPost,
+ http.MethodPut,
+ http.MethodPatch,
+ http.MethodDelete:
+
+ // DELETE does not use a body, but the others do
+ var body []byte
+ if r.Method != http.MethodDelete {
+ if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "/json") {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("unacceptable content-type: %v; 'application/json' required", ct),
+ }
+ }
+
+ buf := bufPool.Get().(*bytes.Buffer)
+ buf.Reset()
+ defer bufPool.Put(buf)
+
+ _, err := io.Copy(buf, r.Body)
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("reading request body: %v", err),
+ }
+ }
+ body = buf.Bytes()
}
+
+ forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
+
+ err := changeConfig(r.Method, r.URL.Path, body, forceReload)
+ if err != nil {
+ return err
+ }
+
+ default:
+ return APIError{
+ Code: http.StatusMethodNotAllowed,
+ Err: fmt.Errorf("method %s not allowed", r.Method),
+ }
+ }
+
+ return nil
+}
+
+func handleConfigID(w http.ResponseWriter, r *http.Request) error {
+ idPath := r.URL.Path
+
+ parts := strings.Split(idPath, "/")
+ if len(parts) < 3 || parts[2] == "" {
+ return fmt.Errorf("request path is missing object ID")
+ }
+ if parts[0] != "" || parts[1] != "id" {
+ return fmt.Errorf("malformed object path")
+ }
+ id := parts[2]
+
+ // map the ID to the expanded path
+ currentCfgMu.RLock()
+ expanded, ok := rawCfgIndex[id]
+ defer currentCfgMu.RUnlock()
+ if !ok {
+ return fmt.Errorf("unknown object ID '%s'", id)
}
- // pass this off to the /config/ endpoint
- r.URL.Path = "/" + rawConfigKey + "/"
- handleConfig(w, r)
+ // piece the full URL path back together
+ parts = append([]string{expanded}, parts[3:]...)
+ r.URL.Path = path.Join(parts...)
+
+ return ErrInternalRedir
}
-func handleStop(w http.ResponseWriter, r *http.Request) {
+func handleUnload(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
+ return APIError{
+ Code: http.StatusMethodNotAllowed,
+ Err: fmt.Errorf("method not allowed"),
+ }
+ }
+ currentCfgMu.RLock()
+ hasCfg := currentCfg != nil
+ currentCfgMu.RUnlock()
+ if !hasCfg {
+ Log().Named("admin.api").Info("nothing to unload")
+ return nil
}
- log.Println("[ADMIN] Initiating shutdown")
+ Log().Named("admin.api").Info("unloading")
if err := stopAndCleanup(); err != nil {
- log.Printf("[ADMIN][ERROR] stopping: %v \n", err)
+ Log().Named("admin.api").Error("error unloading", zap.Error(err))
+ } else {
+ Log().Named("admin.api").Info("unloading completed")
}
- log.Println("[ADMIN] Exiting")
- os.Exit(0)
+ return nil
}
-func stopAndCleanup() error {
- if err := Stop(); err != nil {
- return err
+func handleStop(w http.ResponseWriter, r *http.Request) error {
+ defer func() {
+ Log().Named("admin.api").Info("stopping now, bye!! 👋")
+ os.Exit(0)
+ }()
+ err := handleUnload(w, r)
+ if err != nil {
+ Log().Named("admin.api").Error("unload error", zap.Error(err))
}
- certmagic.CleanUpOwnLocks()
return nil
}
-// Load loads and starts a configuration.
-func Load(r io.Reader) error {
- buf := bufPool.Get().(*bytes.Buffer)
- buf.Reset()
- defer bufPool.Put(buf)
+// unsyncedConfigAccess traverses into the current config and performs
+// the operation at path according to method, using body and out as
+// needed. This is a low-level, unsynchronized function; most callers
+// will want to use changeConfig or readConfig instead. This requires a
+// read or write lock on currentCfgMu, depending on method (GET needs
+// only a read lock; all others need a write lock).
+func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error {
+ var err error
+ var val interface{}
+
+ // if there is a request body, decode it into the
+ // variable that will be set in the config according
+ // to method and path
+ if len(body) > 0 {
+ err = json.Unmarshal(body, &val)
+ if err != nil {
+ return fmt.Errorf("decoding request body: %v", err)
+ }
+ }
- _, err := io.Copy(buf, io.LimitReader(r, 1024*1024))
- if err != nil {
- return err
+ enc := json.NewEncoder(out)
+
+ cleanPath := strings.Trim(path, "/")
+ if cleanPath == "" {
+ return fmt.Errorf("no traversable path")
}
- var cfg *Config
- err = json.Unmarshal(buf.Bytes(), &cfg)
- if err != nil {
- return fmt.Errorf("decoding config: %v", err)
+ parts := strings.Split(cleanPath, "/")
+ if len(parts) == 0 {
+ return fmt.Errorf("path missing")
}
- err = Run(cfg)
- if err != nil {
- return fmt.Errorf("running: %v", err)
+ 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 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",
+ path, idxStr, err)
+ }
+ if idx < 0 || idx >= len(arr) {
+ return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
+ }
+ }
+
+ switch 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", method)
+ }
+ break traverseLoop
+ }
+
+ if i == len(parts)-1 {
+ switch 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", path, part)
+ }
+ v[part] = val
+ case http.MethodPatch:
+ if _, ok := v[part]; !ok {
+ return fmt.Errorf("[%s] key does not exist: %s", path, part)
+ }
+ v[part] = val
+ case http.MethodDelete:
+ delete(v, part)
+ default:
+ return fmt.Errorf("unrecognized method %s", 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])
+ }
}
return nil
}
-// DefaultAdminListen is the address for the admin
-// listener, if none is specified at startup.
-var DefaultAdminListen = "localhost:2019"
+// AdminHandler is like http.Handler except ServeHTTP may return an error.
+//
+// If any handler encounters an error, it should be returned for proper
+// handling.
+type AdminHandler interface {
+ ServeHTTP(http.ResponseWriter, *http.Request) error
+}
+
+// AdminHandlerFunc is a convenience type like http.HandlerFunc.
+type AdminHandlerFunc func(http.ResponseWriter, *http.Request) error
+
+// ServeHTTP implements the Handler interface.
+func (f AdminHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
+ return f(w, r)
+}
+
+// APIError is a structured error that every API
+// handler should return for consistency in logging
+// and client responses. If Message is unset, then
+// Err.Error() will be serialized in its place.
+type APIError struct {
+ Code int `json:"-"`
+ Err error `json:"-"`
+ Message string `json:"error"`
+}
+
+func (e APIError) Error() string {
+ if e.Err != nil {
+ return e.Err.Error()
+ }
+ return e.Message
+}
+
+var (
+ // DefaultAdminListen is the address for the admin
+ // listener, if none is specified at startup.
+ DefaultAdminListen = "localhost:2019"
+
+ // ErrInternalRedir indicates an internal redirect
+ // and is useful when admin API handlers rewrite
+ // the request; in that case, authentication and
+ // authorization needs to happen again for the
+ // rewritten request.
+ ErrInternalRedir = fmt.Errorf("internal redirect; re-authorization required")
+
+ // DefaultAdminConfig is the default configuration
+ // for the administration endpoint.
+ DefaultAdminConfig = &AdminConfig{
+ Listen: DefaultAdminListen,
+ }
+)
+
+const (
+ rawConfigKey = "config"
+ idKey = "@id"
+)
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
+
+var adminServer *http.Server
diff --git a/admin_test.go b/admin_test.go
index 7fdac74..e8563b7 100644
--- a/admin_test.go
+++ b/admin_test.go
@@ -15,14 +15,96 @@
package caddy
import (
- "strings"
+ "encoding/json"
+ "reflect"
"testing"
)
+func TestUnsyncedConfigAccess(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"]}`,
+ },
+ } {
+ err := unsyncedConfigAccess(tc.method, rawConfigKey+tc.path, []byte(tc.payload), nil)
+
+ 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)
+ }
+
+ // 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])
+ }
+ }
+}
+
func BenchmarkLoad(b *testing.B) {
for i := 0; i < b.N; i++ {
- r := strings.NewReader(`{
- "testval": "Yippee!",
+ cfg := []byte(`{
"apps": {
"http": {
"servers": {
@@ -39,6 +121,6 @@ func BenchmarkLoad(b *testing.B) {
}
}
`)
- Load(r)
+ Load(cfg, true)
}
}
diff --git a/caddy.go b/caddy.go
index 33e6296..7aab8d4 100644
--- a/caddy.go
+++ b/caddy.go
@@ -15,11 +15,16 @@
package caddy
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "io"
"log"
+ "net/http"
+ "path"
"runtime/debug"
+ "strconv"
"strings"
"sync"
"time"
@@ -27,7 +32,18 @@ import (
"github.com/mholt/certmagic"
)
-// Config represents a Caddy configuration.
+// Config represents a Caddy configuration. It is the
+// top of the module structure: all Caddy modules will
+// be loaded, starting with this struct. In order to
+// be loaded and run successfully, a Config and all its
+// modules must be JSON-encodable; i.e. when filling a
+// Config struct manually, its JSON-encodable fields
+// (the ones with JSON struct tags, usually ending with
+// "Raw" if they decode into a separate field) must be
+// set so that they can be unmarshaled and provisioned.
+// Setting the fields for the decoded values instead
+// will result in those values being overwritten at
+// unmarshaling/provisioning.
type Config struct {
Admin *AdminConfig `json:"admin,omitempty"`
Logging *Logging `json:"logging,omitempty"`
@@ -46,13 +62,164 @@ type App interface {
Stop() error
}
-// Run runs Caddy with the given config.
-func Run(newCfg *Config) error {
+// Run runs the given config, replacing any existing config.
+func Run(cfg *Config) error {
+ cfgJSON, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ return Load(cfgJSON, true)
+}
+
+// Load loads the given config JSON and runs it only
+// if it is different from the current config or
+// forceReload is true.
+func Load(cfgJSON []byte, forceReload bool) error {
+ return changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, forceReload)
+}
+
+// changeConfig changes the current config (rawCfg) according to the
+// method, traversed via the given path, and uses the given input as
+// the new value (if applicable; i.e. "DELETE" doesn't have an input).
+// If the resulting config is the same as the previous, no reload will
+// occur unless forceReload is true. This function is safe for
+// concurrent use.
+func changeConfig(method, path string, input []byte, forceReload bool) error {
+ switch method {
+ case http.MethodGet,
+ http.MethodHead,
+ http.MethodOptions,
+ http.MethodConnect,
+ http.MethodTrace:
+ return fmt.Errorf("method not allowed")
+ }
+
currentCfgMu.Lock()
defer currentCfgMu.Unlock()
+ err := unsyncedConfigAccess(method, path, input, nil)
+ if err != nil {
+ return err
+ }
+
+ // find any IDs in this config and index them
+ idx := make(map[string]string)
+ err = indexConfigObjects(rawCfg[rawConfigKey], "/"+rawConfigKey, idx)
+ if err != nil {
+ return APIError{
+ Code: http.StatusInternalServerError,
+ Err: fmt.Errorf("indexing config: %v", err),
+ }
+ }
+
+ // the mutation is complete, so encode the entire config as JSON
+ newCfg, err := json.Marshal(rawCfg[rawConfigKey])
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("encoding new config: %v", err),
+ }
+ }
+
+ // if nothing changed, no need to do a whole reload unless the client forces it
+ if !forceReload && bytes.Equal(rawCfgJSON, newCfg) {
+ Log().Named("admin.api.change_config").Info("config is unchanged")
+ return nil
+ }
+
+ // load this new config; if it fails, we need to revert to
+ // our old representation of caddy's actual config
+ err = unsyncedDecodeAndRun(newCfg)
+ if err != nil {
+ if len(rawCfgJSON) > 0 {
+ // 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
+ }
+
+ return fmt.Errorf("loading new config: %v", err)
+ }
+
+ // 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
+
+ return nil
+}
+
+// readConfig traverses the current config to path
+// and writes its JSON encoding to out.
+func readConfig(path string, out io.Writer) error {
+ currentCfgMu.RLock()
+ defer currentCfgMu.RUnlock()
+ return unsyncedConfigAccess(http.MethodGet, path, nil, out)
+}
+
+// indexConfigObjects 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; obtain a write lock
+// on currentCfgMu.
+func indexConfigObjects(ptr interface{}, configPath string, index map[string]string) error {
+ switch val := ptr.(type) {
+ case map[string]interface{}:
+ for k, v := range val {
+ if k == idKey {
+ 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: %s field must be a string or number", configPath, idKey)
+ }
+ delete(val, idKey) // field is no longer needed, and will break config if not removed
+ continue
+ }
+ // traverse this object property recursively
+ err := indexConfigObjects(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 := indexConfigObjects(val[i], path.Join(configPath, strconv.Itoa(i)), index)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// unsyncedDecodeAndRun decodes cfgJSON and runs
+// it as the new config, replacing any other
+// current config. It does not update the raw
+// config state, as this is a lower-level function;
+// most callers will want to use Load instead.
+// A write lock on currentCfgMu is required!
+func unsyncedDecodeAndRun(cfgJSON []byte) error {
+ var newCfg *Config
+ err := strictUnmarshalJSON(cfgJSON, &newCfg)
+ if err != nil {
+ return err
+ }
+
// run the new config and start all its apps
- err := run(newCfg, true)
+ err = run(newCfg, true)
if err != nil {
return err
}
@@ -77,11 +244,11 @@ func Run(newCfg *Config) error {
// the config if you are not going to start it,
// so that each provisioned module will be
// cleaned up.
+//
+// This is a low-level function; most callers
+// will want to use Run instead, which also
+// updates the config's raw state.
func run(newCfg *Config, start bool) error {
- if newCfg == nil {
- return nil
- }
-
// because we will need to roll back any state
// modifications if this function errors, we
// keep a single error value and scope all
@@ -91,6 +258,16 @@ func run(newCfg *Config, start bool) error {
// been set by a short assignment
var err error
+ // start the admin endpoint (and stop any prior one)
+ err = replaceAdmin(newCfg)
+ if err != nil {
+ return fmt.Errorf("starting caddy administration endpoint: %v", err)
+ }
+
+ if newCfg == nil {
+ return nil
+ }
+
// prepare the new config for use
newCfg.apps = make(map[string]App)
@@ -198,22 +375,25 @@ func run(newCfg *Config, start bool) error {
// It is the antithesis of Run(). This function
// will log any errors that occur during the
// stopping of individual apps and continue to
-// stop the others.
+// stop the others. Stop should only be called
+// if not replacing with a new config.
func Stop() error {
currentCfgMu.Lock()
defer currentCfgMu.Unlock()
unsyncedStop(currentCfg)
currentCfg = nil
+ rawCfgJSON = nil
+ rawCfgIndex = nil
+ rawCfg[rawConfigKey] = nil
return nil
}
-// unsyncedStop stops cfg from running, but if
-// applicable, you need to acquire locks yourself.
-// It is a no-op if cfg is nil. If any app
-// returns an error when stopping, it is logged
-// and the function continues with the next app.
-// This function assumes all apps in cfg were
-// successfully started.
+// unsyncedStop stops cfg from running, but has
+// no locking around cfg. It is a no-op if cfg is
+// nil. If any app returns an error when stopping,
+// it is logged and the function continues stopping
+// the next app. This function assumes all apps in
+// cfg were successfully started first.
func unsyncedStop(cfg *Config) {
if cfg == nil {
return
@@ -231,6 +411,17 @@ func unsyncedStop(cfg *Config) {
cfg.cancelFunc()
}
+// stopAndCleanup calls stop and cleans up anything
+// else that is expedient. This should only be used
+// when stopping and not replacing with a new config.
+func stopAndCleanup() error {
+ if err := Stop(); err != nil {
+ return err
+ }
+ certmagic.CleanUpOwnLocks()
+ return nil
+}
+
// Validate loads, provisions, and validates
// cfg, but does not start running it.
func Validate(cfg *Config) error {
@@ -289,8 +480,28 @@ func goModule(mod *debug.Module) *debug.Module {
// CtxKey is a value type for use with context.WithValue.
type CtxKey string
-// currentCfg is the currently-loaded configuration.
+// This group of variables pertains to the current configuration.
var (
- currentCfg *Config
+ // currentCfgMu protects everything in this var block.
currentCfgMu sync.RWMutex
+
+ // currentCfg is the currently-running configuration.
+ currentCfg *Config
+
+ // 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 is the JSON-encoded form of rawCfg. Keeping
+ // this around avoids an extra Marshal call during changes.
+ rawCfgJSON []byte
+
+ // rawCfgIndex is the map of user-assigned ID to expanded
+ // path, for converting /id/ paths to /config/ paths.
+ rawCfgIndex map[string]string
)
diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go
index d73644c..e61967b 100644
--- a/cmd/commandfuncs.go
+++ b/cmd/commandfuncs.go
@@ -160,23 +160,12 @@ func cmdRun(fl Flags) (int, error) {
cleanModVersion := strings.TrimPrefix(goModule.Version, "v")
certmagic.UserAgent = "Caddy/" + cleanModVersion
- // start the admin endpoint along with any initial config
- // a configuration without admin config is considered fine
- // but does not enable the admin endpoint at all
- err = caddy.StartAdmin(config)
- if err == nil {
- defer caddy.StopAdmin()
- } else if err != caddy.ErrAdminInterfaceNotConfigured {
- return caddy.ExitCodeFailedStartup,
- fmt.Errorf("starting caddy administration endpoint: %v", err)
+ // run the initial config
+ err = caddy.Load(config, true)
+ if err != nil {
+ return caddy.ExitCodeFailedStartup, fmt.Errorf("loading initial config: %v", err)
}
-
- // if a config has been supplied, load it as initial config
if len(config) > 0 {
- err := caddy.Load(bytes.NewReader(config))
- if err != nil {
- return caddy.ExitCodeFailedStartup, fmt.Errorf("loading initial config: %v", err)
- }
caddy.Log().Named("admin").Info("Caddy 2 serving initial configuration")
}
diff --git a/dynamicconfig.go b/dynamicconfig.go
deleted file mode 100644
index 0e44376..0000000
--- a/dynamicconfig.go
+++ /dev/null
@@ -1,358 +0,0 @@
-// 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
deleted file mode 100644
index 372c756..0000000
--- a/dynamicconfig_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-// 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])
- }
- }
-}
diff --git a/go.mod b/go.mod
index 52fa150..a9d3179 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,6 @@ require (
github.com/Masterminds/sprig/v3 v3.0.0
github.com/andybalholm/brotli v0.0.0-20190821151343-b60f0d972eeb
github.com/dustin/go-humanize v1.0.0
- github.com/dvyukov/go-fuzz v0.0.0-20191022152526-8cb203812681 // indirect
github.com/go-acme/lego/v3 v3.1.0
github.com/golang/groupcache v0.0.0-20191002201903-404acd9df4cc
github.com/ilibs/json5 v1.0.1
@@ -20,7 +19,6 @@ require (
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20190906142622-1265e9b150c6
github.com/onsi/ginkgo v1.8.0 // indirect
github.com/onsi/gomega v1.5.0 // indirect
- github.com/rs/cors v1.7.0
github.com/russross/blackfriday/v2 v2.0.1
github.com/starlight-go/starlight v0.0.0-20181207205707-b06f321544f3
github.com/vulcand/oxy v1.0.0
@@ -29,6 +27,5 @@ require (
go.uber.org/zap v1.10.0
golang.org/x/crypto v0.0.0-20191010185427-af544f31c8ac
golang.org/x/net v0.0.0-20191009170851-d66e71096ffb
- golang.org/x/time v0.0.0-20190921001708-c4c64cad1fd0
gopkg.in/natefinch/lumberjack.v2 v2.0.0
)
diff --git a/go.sum b/go.sum
index ea4ff17..9ef4997 100644
--- a/go.sum
+++ b/go.sum
@@ -60,8 +60,6 @@ github.com/dnaeon/go-vcr v0.0.0-20180814043457-aafff18a5cc2/go.mod h1:aBB1+wY4s9
github.com/dnsimple/dnsimple-go v0.30.0/go.mod h1:O5TJ0/U6r7AfT8niYNlmohpLbCSG+c71tQlGr9SeGrg=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/dvyukov/go-fuzz v0.0.0-20191022152526-8cb203812681 h1:3WV5aRRj1ELP3RcLlBp/v0WJTuy47OQMkL9GIQq8QEE=
-github.com/dvyukov/go-fuzz v0.0.0-20191022152526-8cb203812681/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
@@ -236,8 +234,6 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa
github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
-github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sacloud/libsacloud v1.26.1/go.mod h1:79ZwATmHLIFZIMd7sxA3LwzVy/B77uj3LDoToVTxDoQ=