diff options
-rw-r--r-- | admin.go | 799 | ||||
-rw-r--r-- | admin_test.go | 90 | ||||
-rw-r--r-- | caddy.go | 247 | ||||
-rw-r--r-- | cmd/commandfuncs.go | 19 | ||||
-rw-r--r-- | dynamicconfig.go | 358 | ||||
-rw-r--r-- | dynamicconfig_test.go | 123 | ||||
-rw-r--r-- | go.mod | 3 | ||||
-rw-r--r-- | go.sum | 4 |
8 files changed, 961 insertions, 682 deletions
@@ -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) } } @@ -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]) - } - } -} @@ -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 ) @@ -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= |