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.
This commit is contained in:
Matthew Holt 2019-11-04 12:05:20 -07:00
parent fb06c041c4
commit 35f70c98fa
No known key found for this signature in database
GPG Key ID: 2A349DD577D586A5
8 changed files with 962 additions and 683 deletions

801
admin.go
View File

@ -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

View File

@ -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)
}
}

247
caddy.go
View File

@ -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
)

View File

@ -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")
}

View File

@ -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"

View File

@ -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])
}
}
}

3
go.mod
View File

@ -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
)

4
go.sum
View File

@ -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=