From 35f70c98fa1ea13882ee4f0406cd17f5545d0100 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 4 Nov 2019 12:05:20 -0700 Subject: [PATCH] core: Major refactor of admin endpoint and config handling Fixed several bugs and made other improvements. All config changes are now mediated by the global config state manager. It used to be that initial configs given at startup weren't tracked, so you could start caddy with --config caddy.json and then do a GET /config/ and it would return null. That is fixed, along with several other general flow/API enhancements, with more to come. --- admin.go | 801 +++++++++++++++++++++++++++++++++--------- admin_test.go | 90 ++++- caddy.go | 247 ++++++++++++- cmd/commandfuncs.go | 19 +- dynamicconfig.go | 358 ------------------- dynamicconfig_test.go | 123 ------- go.mod | 3 - go.sum | 4 - 8 files changed, 962 insertions(+), 683 deletions(-) delete mode 100644 dynamicconfig.go delete mode 100644 dynamicconfig_test.go diff --git a/admin.go b/admin.go index e48a4ca6..fb7b34b6 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. +// 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(), + } -// 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() + // 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) + } - 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) + // 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) } } + 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 admin.Origins == nil { + uniqueOrigins[listen] = struct{}{} + } + var allowed []string + for origin := range uniqueOrigins { + allowed = append(allowed, origin) + } + return allowed +} + +// 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) + } + }() + + // always get a valid admin config 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 - } + if cfg != nil && cfg.Admin != nil { + adminConfig = cfg.Admin + } + + // if new admin endpoint is to be disabled, we're done + if adminConfig.Disabled { + Log().Named("admin").Warn("admin endpoint disabled") + return nil } // extract a singular listener address - netw, listenAddrs, err := ParseNetworkAddress(adminConfig.Listen) - if err != nil { - return fmt.Errorf("parsing admin listener address: %v", err) - } - if len(listenAddrs) != 1 { - return fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs) - } - ln, err := net.Listen(netw, listenAddrs[0]) + netw, addr, err := adminConfig.listenAddr() if err != nil { return err } - mux := http.NewServeMux() - mux.HandleFunc("/load", handleLoadConfig) - mux.HandleFunc("/stop", handleStop) + handler := adminConfig.newAdminHandler(addr) - ///// 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 ////// - - for _, m := range GetModules("admin.routers") { - adminrtr := m.New().(AdminRouter) - for _, route := range adminrtr.Routes() { - mux.Handle(route.Pattern, route) - } + ln, err := Listen(netw, addr) + if err != nil { + return err } - 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) - }) - - cfgEndptSrv = &http.Server{ + 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 } } - // pass this off to the /config/ endpoint - r.URL.Path = "/" + rawConfigKey + "/" - handleConfig(w, r) + 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 handleStop(w http.ResponseWriter, r *http.Request) { +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) + } + + // piece the full URL path back together + parts = append([]string{expanded}, parts[3:]...) + r.URL.Path = path.Join(parts...) + + return ErrInternalRedir +} + +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"), + } } - log.Println("[ADMIN] Initiating shutdown") + currentCfgMu.RLock() + hasCfg := currentCfg != nil + currentCfgMu.RUnlock() + if !hasCfg { + Log().Named("admin.api").Info("nothing to unload") + return nil + } + 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) -} - -func stopAndCleanup() error { - if err := Stop(); err != nil { - return 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) - - _, err := io.Copy(buf, io.LimitReader(r, 1024*1024)) +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 { - return err + Log().Named("admin.api").Error("unload error", zap.Error(err)) + } + return nil +} + +// 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) + } } - var cfg *Config - err = json.Unmarshal(buf.Bytes(), &cfg) - if err != nil { - return fmt.Errorf("decoding config: %v", err) + enc := json.NewEncoder(out) + + cleanPath := strings.Trim(path, "/") + if cleanPath == "" { + return fmt.Errorf("no traversable path") } - err = Run(cfg) - if err != nil { - return fmt.Errorf("running: %v", err) + 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 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 7fdac741..e8563b71 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 33e62968..7aab8d43 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 d73644c2..e61967b9 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 0e44376f..00000000 --- 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 372c7563..00000000 --- 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 52fa150e..a9d31799 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 ea4ff17d..9ef4997a 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=