summaryrefslogtreecommitdiff
path: root/admin.go
diff options
context:
space:
mode:
authorMatthew Holt <mholt@users.noreply.github.com>2019-11-04 12:05:20 -0700
committerMatthew Holt <mholt@users.noreply.github.com>2019-11-04 12:05:20 -0700
commit35f70c98fa1ea13882ee4f0406cd17f5545d0100 (patch)
tree71d8a35e62910802431cc4c650da7365ac21041f /admin.go
parentfb06c041c4be4eb32f18d54e8e7feff8dd76b0e9 (diff)
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.
Diffstat (limited to 'admin.go')
-rw-r--r--admin.go799
1 files changed, 642 insertions, 157 deletions
diff --git a/admin.go b/admin.go
index e48a4ca..fb7b34b 100644
--- a/admin.go
+++ b/admin.go
@@ -18,157 +18,202 @@ import (
"bytes"
"context"
"encoding/json"
- "errors"
+ "expvar"
"fmt"
"io"
- "io/ioutil"
- "log"
"mime"
- "net"
"net/http"
"net/http/pprof"
+ "net/url"
"os"
+ "path"
+ "strconv"
"strings"
"sync"
"time"
"github.com/caddyserver/caddy/v2/caddyconfig"
- "github.com/mholt/certmagic"
- "github.com/rs/cors"
"go.uber.org/zap"
)
-var (
- cfgEndptSrv *http.Server
- cfgEndptSrvMu sync.Mutex
-)
-
-var ErrAdminInterfaceNotConfigured = errors.New("no admin configuration has been set")
+// TODO: is there a way to make the admin endpoint so that it can be plugged into the HTTP app? see issue #2833
// AdminConfig configures the admin endpoint.
type AdminConfig struct {
- Listen string `json:"listen,omitempty"`
+ Disabled bool `json:"disabled,omitempty"`
+ Listen string `json:"listen,omitempty"`
+ EnforceOrigin bool `json:"enforce_origin,omitempty"`
+ Origins []string `json:"origins,omitempty"`
}
-// DefaultAdminConfig is the default configuration
-// for the administration endpoint.
-var DefaultAdminConfig = &AdminConfig{
- Listen: DefaultAdminListen,
+// listenAddr extracts a singular listen address from ac.Listen,
+// returning the network and the address of the listener.
+func (admin AdminConfig) listenAddr() (netw string, addr string, err error) {
+ var listenAddrs []string
+ input := admin.Listen
+ if input == "" {
+ input = DefaultAdminListen
+ }
+ netw, listenAddrs, err = ParseNetworkAddress(input)
+ if err != nil {
+ err = fmt.Errorf("parsing admin listener address: %v", err)
+ return
+ }
+ if len(listenAddrs) != 1 {
+ err = fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs)
+ return
+ }
+ addr = listenAddrs[0]
+ return
}
-// TODO: holy smokes, the admin endpoint might not have to live in caddy's core.
-
-// StartAdmin starts Caddy's administration endpoint,
-// bootstrapping it with an optional configuration
-// in the format of JSON bytes. It opens a listener
-// resource. When no longer needed, StopAdmin should
-// be called.
-// If no configuration is given, a default listener is
-// started. If a configuration is given that does NOT
-// specifically configure the admin interface,
-// `ErrAdminInterfaceNotConfigured` is returned and no
-// listener is initialized.
-func StartAdmin(initialConfigJSON []byte) error {
- cfgEndptSrvMu.Lock()
- defer cfgEndptSrvMu.Unlock()
-
- if cfgEndptSrv != nil {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- err := cfgEndptSrv.Shutdown(ctx)
- if err != nil {
- return fmt.Errorf("shutting down old admin endpoint: %v", err)
- }
+// newAdminHandler reads admin's config and returns an http.Handler suitable
+// for use in an admin endpoint server, which will be listening on listenAddr.
+func (admin AdminConfig) newAdminHandler(listenAddr string) adminHandler {
+ muxWrap := adminHandler{
+ enforceOrigin: admin.EnforceOrigin,
+ allowedOrigins: admin.allowedOrigins(listenAddr),
+ mux: http.NewServeMux(),
}
- adminConfig := DefaultAdminConfig
- if len(initialConfigJSON) > 0 {
- var config *Config
- err := json.Unmarshal(initialConfigJSON, &config)
- if err != nil {
- return fmt.Errorf("unmarshaling bootstrap config: %v", err)
- }
- if config != nil {
- if config.Admin == nil {
- return ErrAdminInterfaceNotConfigured
- }
- adminConfig = config.Admin
+ // addRoute just calls muxWrap.mux.Handle after
+ // wrapping the handler with error handling
+ addRoute := func(pattern string, h AdminHandler) {
+ wrapper := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ err := h.ServeHTTP(w, r)
+ muxWrap.handleError(w, r, err)
+ })
+ muxWrap.mux.Handle(pattern, wrapper)
+ }
+
+ // register standard config control endpoints
+ addRoute("/load", AdminHandlerFunc(handleLoad))
+ addRoute("/"+rawConfigKey+"/", AdminHandlerFunc(handleConfig))
+ addRoute("/id/", AdminHandlerFunc(handleConfigID))
+ addRoute("/unload", AdminHandlerFunc(handleUnload))
+ addRoute("/stop", AdminHandlerFunc(handleStop))
+
+ // register debugging endpoints
+ muxWrap.mux.HandleFunc("/debug/pprof/", pprof.Index)
+ muxWrap.mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
+ muxWrap.mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
+ muxWrap.mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
+ muxWrap.mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
+ muxWrap.mux.Handle("/debug/vars", expvar.Handler())
+
+ // register third-party module endpoints
+ for _, m := range GetModules("admin.api") {
+ router := m.New().(AdminRouter)
+ for _, route := range router.Routes() {
+ addRoute(route.Pattern, route.Handler)
}
}
- // extract a singular listener address
- netw, listenAddrs, err := ParseNetworkAddress(adminConfig.Listen)
- if err != nil {
- return fmt.Errorf("parsing admin listener address: %v", err)
+ return muxWrap
+}
+
+// allowedOrigins returns a list of origins that are allowed.
+// If admin.Origins is nil (null), the provided listen address
+// will be used as the default origin. If admin.Origins is
+// empty, no origins will be allowed, effectively bricking the
+// endpoint, but whatever.
+func (admin AdminConfig) allowedOrigins(listen string) []string {
+ uniqueOrigins := make(map[string]struct{})
+ for _, o := range admin.Origins {
+ uniqueOrigins[o] = struct{}{}
}
- if len(listenAddrs) != 1 {
- return fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddrs)
+ if admin.Origins == nil {
+ uniqueOrigins[listen] = struct{}{}
}
- ln, err := net.Listen(netw, listenAddrs[0])
- if err != nil {
- return err
+ var allowed []string
+ for origin := range uniqueOrigins {
+ allowed = append(allowed, origin)
}
+ return allowed
+}
- mux := http.NewServeMux()
- mux.HandleFunc("/load", handleLoadConfig)
- mux.HandleFunc("/stop", handleStop)
+// replaceAdmin replaces the running admin server according
+// to the relevant configuration in cfg. If no configuration
+// for the admin endpoint exists in cfg, a default one is
+// used, so that there is always an admin server (unless it
+// is explicitly configured to be disabled).
+func replaceAdmin(cfg *Config) error {
+ // always be sure to close down the old admin endpoint
+ // as gracefully as possible, even if the new one is
+ // disabled -- careful to use reference to the current
+ // (old) admin endpoint since it will be different
+ // when the function returns
+ oldAdminServer := adminServer
+ defer func() {
+ // do the shutdown asynchronously so that any
+ // current API request gets a response; this
+ // goroutine may last a few seconds
+ if oldAdminServer != nil {
+ go func(oldAdminServer *http.Server) {
+ err := stopAdminServer(oldAdminServer)
+ if err != nil {
+ Log().Named("admin").Error("stopping current admin endpoint", zap.Error(err))
+ }
+ }(oldAdminServer)
+ }
+ }()
- ///// BEGIN PPROF STUFF (TODO: Temporary) /////
- mux.HandleFunc("/debug/pprof/", pprof.Index)
- mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
- mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
- mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
- mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
- ///// END PPROF STUFF //////
+ // always get a valid admin config
+ adminConfig := DefaultAdminConfig
+ if cfg != nil && cfg.Admin != nil {
+ adminConfig = cfg.Admin
+ }
- for _, m := range GetModules("admin.routers") {
- adminrtr := m.New().(AdminRouter)
- for _, route := range adminrtr.Routes() {
- mux.Handle(route.Pattern, route)
- }
+ // if new admin endpoint is to be disabled, we're done
+ if adminConfig.Disabled {
+ Log().Named("admin").Warn("admin endpoint disabled")
+ return nil
}
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // TODO: improve/organize this logging
- Log().Named("admin.request").Info("",
- zap.String("method", r.Method),
- zap.String("uri", r.RequestURI),
- zap.String("remote", r.RemoteAddr),
- )
- cors.Default().Handler(mux).ServeHTTP(w, r)
- })
+ // extract a singular listener address
+ netw, addr, err := adminConfig.listenAddr()
+ if err != nil {
+ return err
+ }
- cfgEndptSrv = &http.Server{
+ handler := adminConfig.newAdminHandler(addr)
+
+ ln, err := Listen(netw, addr)
+ if err != nil {
+ return err
+ }
+
+ adminServer = &http.Server{
Handler: handler,
- ReadTimeout: 5 * time.Second,
+ ReadTimeout: 10 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
- IdleTimeout: 5 * time.Second,
+ IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1024 * 64,
}
- go cfgEndptSrv.Serve(ln)
+ go adminServer.Serve(ln)
- Log().Named("admin").Info("Caddy 2 admin endpoint started.", zap.String("listenAddress", adminConfig.Listen))
+ Log().Named("admin").Info(
+ "admin endpoint started",
+ zap.String("address", addr),
+ zap.Bool("enforce_origin", adminConfig.EnforceOrigin),
+ zap.Strings("origins", handler.allowedOrigins),
+ )
return nil
}
-// StopAdmin stops the API endpoint.
-func StopAdmin() error {
- cfgEndptSrvMu.Lock()
- defer cfgEndptSrvMu.Unlock()
-
- if cfgEndptSrv == nil {
- return fmt.Errorf("no server")
+func stopAdminServer(srv *http.Server) error {
+ if srv == nil {
+ return fmt.Errorf("no admin server")
}
-
- err := cfgEndptSrv.Shutdown(context.Background()) // TODO
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ err := srv.Shutdown(ctx)
if err != nil {
- return fmt.Errorf("shutting down server: %v", err)
+ return fmt.Errorf("shutting down admin server: %v", err)
}
-
- cfgEndptSrv = nil
-
+ Log().Named("admin").Info("stopped previous server")
return nil
}
@@ -179,117 +224,557 @@ type AdminRouter interface {
// AdminRoute represents a route for the admin endpoint.
type AdminRoute struct {
- http.Handler
Pattern string
+ Handler AdminHandler
}
-func handleLoadConfig(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+type adminHandler struct {
+ enforceOrigin bool
+ allowedOrigins []string
+ mux *http.ServeMux
+}
+
+// ServeHTTP is the external entry point for API requests.
+// It will only be called once per request.
+func (h adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ Log().Named("admin.api").Info("received request",
+ zap.String("method", r.Method),
+ zap.String("uri", r.RequestURI),
+ zap.String("remote_addr", r.RemoteAddr),
+ zap.Reflect("headers", r.Header),
+ )
+ h.serveHTTP(w, r)
+}
+
+// serveHTTP is the internal entry point for API requests. It may
+// be called more than once per request, for example if a request
+// is rewritten (i.e. internal redirect).
+func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
+ if h.enforceOrigin {
+ // DNS rebinding mitigation
+ err := h.checkHost(r)
+ if err != nil {
+ h.handleError(w, r, err)
+ return
+ }
+
+ // cross-site mitigation
+ origin, err := h.checkOrigin(r)
+ if err != nil {
+ h.handleError(w, r, err)
+ return
+ }
+
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT, PATCH, DELETE")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Cache-Control")
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+
+ // TODO: authentication & authorization, if configured
+
+ h.mux.ServeHTTP(w, r)
+}
+
+func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err error) {
+ if err == nil {
return
}
+ if err == ErrInternalRedir {
+ h.serveHTTP(w, r)
+ return
+ }
+
+ apiErr, ok := err.(APIError)
+ if !ok {
+ apiErr = APIError{
+ Code: http.StatusInternalServerError,
+ Err: err,
+ }
+ }
+ if apiErr.Code == 0 {
+ apiErr.Code = http.StatusInternalServerError
+ }
+ if apiErr.Message == "" && apiErr.Err != nil {
+ apiErr.Message = apiErr.Err.Error()
+ }
+
+ Log().Named("admin.api").Error("request error",
+ zap.Error(err),
+ zap.Int("status_code", apiErr.Code),
+ )
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(apiErr.Code)
+ json.NewEncoder(w).Encode(apiErr)
+}
+
+// checkHost returns a handler that wraps next such that
+// it will only be called if the request's Host header matches
+// a trustworthy/expected value. This helps to mitigate DNS
+// rebinding attacks.
+func (h adminHandler) checkHost(r *http.Request) error {
+ var allowed bool
+ for _, allowedHost := range h.allowedOrigins {
+ if r.Host == allowedHost {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ return APIError{
+ Code: http.StatusForbidden,
+ Err: fmt.Errorf("host not allowed: %s", r.Host),
+ }
+ }
+ return nil
+}
+
+// checkOrigin ensures that the Origin header, if
+// set, matches the intended target; prevents arbitrary
+// sites from issuing requests to our listener. It
+// returns the origin that was obtained from r.
+func (h adminHandler) checkOrigin(r *http.Request) (string, error) {
+ origin := h.getOriginHost(r)
+ if origin == "" {
+ return origin, APIError{
+ Code: http.StatusForbidden,
+ Err: fmt.Errorf("missing required Origin header"),
+ }
+ }
+ if !h.originAllowed(origin) {
+ return origin, APIError{
+ Code: http.StatusForbidden,
+ Err: fmt.Errorf("client is not allowed to access from origin %s", origin),
+ }
+ }
+ return origin, nil
+}
+
+func (h adminHandler) getOriginHost(r *http.Request) string {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ origin = r.Header.Get("Referer")
+ }
+ originURL, err := url.Parse(origin)
+ if err == nil && originURL.Host != "" {
+ origin = originURL.Host
+ }
+ return origin
+}
+
+func (h adminHandler) originAllowed(origin string) bool {
+ for _, allowedOrigin := range h.allowedOrigins {
+ originCopy := origin
+ if !strings.Contains(allowedOrigin, "://") {
+ // no scheme specified, so allow both
+ originCopy = strings.TrimPrefix(originCopy, "http://")
+ originCopy = strings.TrimPrefix(originCopy, "https://")
+ }
+ if originCopy == allowedOrigin {
+ return true
+ }
+ }
+ return false
+}
+
+func handleLoad(w http.ResponseWriter, r *http.Request) error {
+ if r.Method != http.MethodPost {
+ return APIError{
+ Code: http.StatusMethodNotAllowed,
+ Err: fmt.Errorf("method not allowed"),
+ }
+ }
+
+ buf := bufPool.Get().(*bytes.Buffer)
+ buf.Reset()
+ defer bufPool.Put(buf)
+
+ _, err := io.Copy(buf, r.Body)
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("reading request body: %v", err),
+ }
+ }
+ body := buf.Bytes()
// if the config is formatted other than Caddy's native
// JSON, we need to adapt it before loading it
if ctHeader := r.Header.Get("Content-Type"); ctHeader != "" {
ct, _, err := mime.ParseMediaType(ctHeader)
if err != nil {
- http.Error(w, "Invalid Content-Type: "+err.Error(), http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("invalid Content-Type: %v", err),
+ }
}
if !strings.HasSuffix(ct, "/json") {
slashIdx := strings.Index(ct, "/")
if slashIdx < 0 {
- http.Error(w, "Malformed Content-Type", http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("malformed Content-Type"),
+ }
}
adapterName := ct[slashIdx+1:]
cfgAdapter := caddyconfig.GetAdapter(adapterName)
if cfgAdapter == nil {
- http.Error(w, "Unrecognized config adapter: "+adapterName, http.StatusBadRequest)
- return
- }
- body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024))
- if err != nil {
- http.Error(w, "Error reading request body: "+err.Error(), http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("unrecognized config adapter '%s'", adapterName),
+ }
}
result, warnings, err := cfgAdapter.Adapt(body, nil)
if err != nil {
- log.Printf("[ADMIN][ERROR] adapting config from %s: %v", adapterName, err)
- http.Error(w, fmt.Sprintf("Adapting config from %s: %v", adapterName, err), http.StatusBadRequest)
- return
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("adapting config using %s adapter: %v", adapterName, err),
+ }
}
if len(warnings) > 0 {
respBody, err := json.Marshal(warnings)
if err != nil {
- log.Printf("[ADMIN][ERROR] marshaling warnings: %v", err)
+ Log().Named("admin.api.load").Error(err.Error())
}
w.Write(respBody)
}
- // replace original request body with adapted JSON
- r.Body.Close()
- r.Body = ioutil.NopCloser(bytes.NewReader(result))
+ body = result
+ }
+ }
+
+ forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
+
+ err = Load(body, forceReload)
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("loading config: %v", err),
+ }
+ }
+
+ Log().Named("admin.api").Info("load complete")
+
+ return nil
+}
+
+func handleConfig(w http.ResponseWriter, r *http.Request) error {
+ switch r.Method {
+ case http.MethodGet:
+ w.Header().Set("Content-Type", "application/json")
+
+ err := readConfig(r.URL.Path, w)
+ if err != nil {
+ return APIError{Code: http.StatusBadRequest, Err: err}
+ }
+
+ return nil
+
+ case http.MethodPost,
+ http.MethodPut,
+ http.MethodPatch,
+ http.MethodDelete:
+
+ // DELETE does not use a body, but the others do
+ var body []byte
+ if r.Method != http.MethodDelete {
+ if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "/json") {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("unacceptable content-type: %v; 'application/json' required", ct),
+ }
+ }
+
+ buf := bufPool.Get().(*bytes.Buffer)
+ buf.Reset()
+ defer bufPool.Put(buf)
+
+ _, err := io.Copy(buf, r.Body)
+ if err != nil {
+ return APIError{
+ Code: http.StatusBadRequest,
+ Err: fmt.Errorf("reading request body: %v", err),
+ }
+ }
+ body = buf.Bytes()
}
+
+ forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
+
+ err := changeConfig(r.Method, r.URL.Path, body, forceReload)
+ if err != nil {
+ return err
+ }
+
+ default:
+ return APIError{
+ Code: http.StatusMethodNotAllowed,
+ Err: fmt.Errorf("method %s not allowed", r.Method),
+ }
+ }
+
+ return nil
+}
+
+func handleConfigID(w http.ResponseWriter, r *http.Request) error {
+ idPath := r.URL.Path
+
+ parts := strings.Split(idPath, "/")
+ if len(parts) < 3 || parts[2] == "" {
+ return fmt.Errorf("request path is missing object ID")
+ }
+ if parts[0] != "" || parts[1] != "id" {
+ return fmt.Errorf("malformed object path")
+ }
+ id := parts[2]
+
+ // map the ID to the expanded path
+ currentCfgMu.RLock()
+ expanded, ok := rawCfgIndex[id]
+ defer currentCfgMu.RUnlock()
+ if !ok {
+ return fmt.Errorf("unknown object ID '%s'", id)
}
- // pass this off to the /config/ endpoint
- r.URL.Path = "/" + rawConfigKey + "/"
- handleConfig(w, r)
+ // piece the full URL path back together
+ parts = append([]string{expanded}, parts[3:]...)
+ r.URL.Path = path.Join(parts...)
+
+ return ErrInternalRedir
}
-func handleStop(w http.ResponseWriter, r *http.Request) {
+func handleUnload(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- return
+ return APIError{
+ Code: http.StatusMethodNotAllowed,
+ Err: fmt.Errorf("method not allowed"),
+ }
+ }
+ currentCfgMu.RLock()
+ hasCfg := currentCfg != nil
+ currentCfgMu.RUnlock()
+ if !hasCfg {
+ Log().Named("admin.api").Info("nothing to unload")
+ return nil
}
- log.Println("[ADMIN] Initiating shutdown")
+ Log().Named("admin.api").Info("unloading")
if err := stopAndCleanup(); err != nil {
- log.Printf("[ADMIN][ERROR] stopping: %v \n", err)
+ Log().Named("admin.api").Error("error unloading", zap.Error(err))
+ } else {
+ Log().Named("admin.api").Info("unloading completed")
}
- log.Println("[ADMIN] Exiting")
- os.Exit(0)
+ return nil
}
-func stopAndCleanup() error {
- if err := Stop(); err != nil {
- return err
+func handleStop(w http.ResponseWriter, r *http.Request) error {
+ defer func() {
+ Log().Named("admin.api").Info("stopping now, bye!! 👋")
+ os.Exit(0)
+ }()
+ err := handleUnload(w, r)
+ if err != nil {
+ Log().Named("admin.api").Error("unload error", zap.Error(err))
}
- certmagic.CleanUpOwnLocks()
return nil
}
-// Load loads and starts a configuration.
-func Load(r io.Reader) error {
- buf := bufPool.Get().(*bytes.Buffer)
- buf.Reset()
- defer bufPool.Put(buf)
+// unsyncedConfigAccess traverses into the current config and performs
+// the operation at path according to method, using body and out as
+// needed. This is a low-level, unsynchronized function; most callers
+// will want to use changeConfig or readConfig instead. This requires a
+// read or write lock on currentCfgMu, depending on method (GET needs
+// only a read lock; all others need a write lock).
+func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error {
+ var err error
+ var val interface{}
+
+ // if there is a request body, decode it into the
+ // variable that will be set in the config according
+ // to method and path
+ if len(body) > 0 {
+ err = json.Unmarshal(body, &val)
+ if err != nil {
+ return fmt.Errorf("decoding request body: %v", err)
+ }
+ }
- _, err := io.Copy(buf, io.LimitReader(r, 1024*1024))
- if err != nil {
- return err
+ enc := json.NewEncoder(out)
+
+ cleanPath := strings.Trim(path, "/")
+ if cleanPath == "" {
+ return fmt.Errorf("no traversable path")
}
- var cfg *Config
- err = json.Unmarshal(buf.Bytes(), &cfg)
- if err != nil {
- return fmt.Errorf("decoding config: %v", err)
+ parts := strings.Split(cleanPath, "/")
+ if len(parts) == 0 {
+ return fmt.Errorf("path missing")
}
- err = Run(cfg)
- if err != nil {
- return fmt.Errorf("running: %v", err)
+ var ptr interface{} = rawCfg
+
+traverseLoop:
+ for i, part := range parts {
+ switch v := ptr.(type) {
+ case map[string]interface{}:
+ // if the next part enters a slice, and the slice is our destination,
+ // handle it specially (because appending to the slice copies the slice
+ // header, which does not replace the original one like we want)
+ if arr, ok := v[part].([]interface{}); ok && i == len(parts)-2 {
+ var idx int
+ if method != http.MethodPost {
+ idxStr := parts[len(parts)-1]
+ idx, err = strconv.Atoi(idxStr)
+ if err != nil {
+ return fmt.Errorf("[%s] invalid array index '%s': %v",
+ path, idxStr, err)
+ }
+ if idx < 0 || idx >= len(arr) {
+ return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
+ }
+ }
+
+ switch method {
+ case http.MethodGet:
+ err = enc.Encode(arr[idx])
+ if err != nil {
+ return fmt.Errorf("encoding config: %v", err)
+ }
+ case http.MethodPost:
+ v[part] = append(arr, val)
+ case http.MethodPut:
+ // avoid creation of new slice and a second copy (see
+ // https://github.com/golang/go/wiki/SliceTricks#insert)
+ arr = append(arr, nil)
+ copy(arr[idx+1:], arr[idx:])
+ arr[idx] = val
+ v[part] = arr
+ case http.MethodPatch:
+ arr[idx] = val
+ case http.MethodDelete:
+ v[part] = append(arr[:idx], arr[idx+1:]...)
+ default:
+ return fmt.Errorf("unrecognized method %s", method)
+ }
+ break traverseLoop
+ }
+
+ if i == len(parts)-1 {
+ switch method {
+ case http.MethodGet:
+ err = enc.Encode(v[part])
+ if err != nil {
+ return fmt.Errorf("encoding config: %v", err)
+ }
+ case http.MethodPost:
+ if arr, ok := v[part].([]interface{}); ok {
+ // if the part is an existing list, POST appends to it
+ // TODO: Do we ever reach this point, since we handle arrays
+ // separately above?
+ v[part] = append(arr, val)
+ } else {
+ // otherwise, it simply sets the value
+ v[part] = val
+ }
+ case http.MethodPut:
+ if _, ok := v[part]; ok {
+ return fmt.Errorf("[%s] key already exists: %s", path, part)
+ }
+ v[part] = val
+ case http.MethodPatch:
+ if _, ok := v[part]; !ok {
+ return fmt.Errorf("[%s] key does not exist: %s", path, part)
+ }
+ v[part] = val
+ case http.MethodDelete:
+ delete(v, part)
+ default:
+ return fmt.Errorf("unrecognized method %s", method)
+ }
+ } else {
+ ptr = v[part]
+ }
+
+ case []interface{}:
+ partInt, err := strconv.Atoi(part)
+ if err != nil {
+ return fmt.Errorf("[/%s] invalid array index '%s': %v",
+ strings.Join(parts[:i+1], "/"), part, err)
+ }
+ if partInt < 0 || partInt >= len(v) {
+ return fmt.Errorf("[/%s] array index out of bounds: %s",
+ strings.Join(parts[:i+1], "/"), part)
+ }
+ ptr = v[partInt]
+
+ default:
+ return fmt.Errorf("invalid path: %s", parts[:i+1])
+ }
}
return nil
}
-// DefaultAdminListen is the address for the admin
-// listener, if none is specified at startup.
-var DefaultAdminListen = "localhost:2019"
+// AdminHandler is like http.Handler except ServeHTTP may return an error.
+//
+// If any handler encounters an error, it should be returned for proper
+// handling.
+type AdminHandler interface {
+ ServeHTTP(http.ResponseWriter, *http.Request) error
+}
+
+// AdminHandlerFunc is a convenience type like http.HandlerFunc.
+type AdminHandlerFunc func(http.ResponseWriter, *http.Request) error
+
+// ServeHTTP implements the Handler interface.
+func (f AdminHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
+ return f(w, r)
+}
+
+// APIError is a structured error that every API
+// handler should return for consistency in logging
+// and client responses. If Message is unset, then
+// Err.Error() will be serialized in its place.
+type APIError struct {
+ Code int `json:"-"`
+ Err error `json:"-"`
+ Message string `json:"error"`
+}
+
+func (e APIError) Error() string {
+ if e.Err != nil {
+ return e.Err.Error()
+ }
+ return e.Message
+}
+
+var (
+ // DefaultAdminListen is the address for the admin
+ // listener, if none is specified at startup.
+ DefaultAdminListen = "localhost:2019"
+
+ // ErrInternalRedir indicates an internal redirect
+ // and is useful when admin API handlers rewrite
+ // the request; in that case, authentication and
+ // authorization needs to happen again for the
+ // rewritten request.
+ ErrInternalRedir = fmt.Errorf("internal redirect; re-authorization required")
+
+ // DefaultAdminConfig is the default configuration
+ // for the administration endpoint.
+ DefaultAdminConfig = &AdminConfig{
+ Listen: DefaultAdminListen,
+ }
+)
+
+const (
+ rawConfigKey = "config"
+ idKey = "@id"
+)
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
+
+var adminServer *http.Server