diff options
Diffstat (limited to 'admin.go')
| -rw-r--r-- | admin.go | 563 | 
1 files changed, 451 insertions, 112 deletions
| @@ -17,6 +17,10 @@ package caddy  import (  	"bytes"  	"context" +	"crypto" +	"crypto/tls" +	"crypto/x509" +	"encoding/base64"  	"encoding/json"  	"errors"  	"expvar" @@ -35,12 +39,11 @@ import (  	"sync"  	"time" +	"github.com/caddyserver/certmagic"  	"github.com/prometheus/client_golang/prometheus"  	"go.uber.org/zap"  ) -// 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 Caddy's API endpoint, which is used  // to manage Caddy while it is running.  type AdminConfig struct { @@ -58,54 +61,131 @@ type AdminConfig struct {  	// If true, CORS headers will be emitted, and requests to the  	// API will be rejected if their `Host` and `Origin` headers  	// do not match the expected value(s). Use `origins` to -	// customize which origins/hosts are allowed.If `origins` is +	// customize which origins/hosts are allowed. If `origins` is  	// not set, the listen address is the only value allowed by -	// default. +	// default. Enforced only on local (plaintext) endpoint.  	EnforceOrigin bool `json:"enforce_origin,omitempty"`  	// The list of allowed origins/hosts for API requests. Only needed  	// if accessing the admin endpoint from a host different from the  	// socket's network interface or if `enforce_origin` is true. If not  	// set, the listener address will be the default value. If set but -	// empty, no origins will be allowed. +	// empty, no origins will be allowed. Enforced only on local +	// (plaintext) endpoint.  	Origins []string `json:"origins,omitempty"` -	// Options related to configuration management. +	// Options pertaining to configuration management.  	Config *ConfigSettings `json:"config,omitempty"` + +	// Options that establish this server's identity. Identity refers to +	// credentials which can be used to uniquely identify and authenticate +	// this server instance. This is required if remote administration is +	// enabled (but does not require remote administration to be enabled). +	// Default: no identity management. +	Identity *IdentityConfig `json:"identity,omitempty"` + +	// Options pertaining to remote administration. By default, remote +	// administration is disabled. If enabled, identity management must +	// also be configured, as that is how the endpoint is secured. +	// See the neighboring "identity" object. +	// +	// EXPERIMENTAL: This feature is subject to change. +	Remote *RemoteAdmin `json:"remote,omitempty"`  } -// ConfigSettings configures the, uh, configuration... and -// management thereof. +// ConfigSettings configures the management of configuration.  type ConfigSettings struct {  	// Whether to keep a copy of the active config on disk. Default is true. +	// Note that "pulled" dynamic configs (using the neighboring "load" module) +	// are not persisted; only configs that are pushed to Caddy get persisted.  	Persist *bool `json:"persist,omitempty"` + +	// Loads a configuration to use. This is helpful if your configs are +	// managed elsewhere, and you want Caddy to pull its config dynamically +	// when it starts. The pulled config completely replaces the current +	// one, just like any other config load. It is an error if a pulled +	// config is configured to pull another config. +	// +	// EXPERIMENTAL: Subject to change. +	LoadRaw json.RawMessage `json:"load,omitempty" caddy:"namespace=caddy.config_loaders inline_key=module"`  } -// listenAddr extracts a singular listen address from ac.Listen, -// returning the network and the address of the listener. -func (admin AdminConfig) listenAddr() (NetworkAddress, error) { -	input := admin.Listen -	if input == "" { -		input = DefaultAdminListen -	} -	listenAddr, err := ParseNetworkAddress(input) -	if err != nil { -		return NetworkAddress{}, fmt.Errorf("parsing admin listener address: %v", err) -	} -	if listenAddr.PortRangeSize() != 1 { -		return NetworkAddress{}, fmt.Errorf("admin endpoint must have exactly one address; cannot listen on %v", listenAddr) -	} -	return listenAddr, nil +// IdentityConfig configures management of this server's identity. An identity +// consists of credentials that uniquely verify this instance; for example, +// TLS certificates (public + private key pairs). +type IdentityConfig struct { +	// List of names or IP addresses which refer to this server. +	// Certificates will be obtained for these identifiers so +	// secure TLS connections can be made using them. +	Identifiers []string `json:"identifiers,omitempty"` + +	// Issuers that can provide this admin endpoint its identity +	// certificate(s). Default: ACME issuers configured for +	// ZeroSSL and Let's Encrypt. Be sure to change this if you +	// require credentials for private identifiers. +	IssuersRaw []json.RawMessage `json:"issuers,omitempty" caddy:"namespace=tls.issuance inline_key=module"` + +	issuers []certmagic.Issuer +} + +// RemoteAdmin enables and configures remote administration. If enabled, +// a secure listener enforcing mutual TLS authentication will be started +// on a different port from the standard plaintext admin server. +// +// This endpoint is secured using identity management, which must be +// configured separately (because identity management does not depend +// on remote administration). See the admin/identity config struct. +// +// EXPERIMENTAL: Subject to change. +type RemoteAdmin struct { +	// The address on which to start the secure listener. +	// Default: :2021 +	Listen string `json:"listen,omitempty"` + +	// List of access controls for this secure admin endpoint. +	// This configures TLS mutual authentication (i.e. authorized +	// client certificates), but also application-layer permissions +	// like which paths and methods each identity is authorized for. +	AccessControl []*AdminAccess `json:"access_control,omitempty"` +} + +// AdminAccess specifies what permissions an identity or group +// of identities are granted. +type AdminAccess struct { +	// Base64-encoded DER certificates containing public keys to accept. +	// (The contents of PEM certificate blocks are base64-encoded DER.) +	// Any of these public keys can appear in any part of a verified chain. +	PublicKeys []string `json:"public_keys,omitempty"` + +	// Limits what the associated identities are allowed to do. +	// If unspecified, all permissions are granted. +	Permissions []AdminPermissions `json:"permissions,omitempty"` + +	publicKeys []crypto.PublicKey +} + +// AdminPermissions specifies what kinds of requests are allowed +// to be made to the admin endpoint. +type AdminPermissions struct { +	// The API paths allowed. Paths are simple prefix matches. +	// Any subpath of the specified paths will be allowed. +	Paths []string `json:"paths,omitempty"` + +	// The HTTP methods allowed for the given paths. +	Methods []string `json:"methods,omitempty"`  }  // 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(addr NetworkAddress) adminHandler { -	muxWrap := adminHandler{ -		enforceOrigin:  admin.EnforceOrigin, -		enforceHost:    !addr.isWildcardInterface(), -		allowedOrigins: admin.allowedOrigins(addr), -		mux:            http.NewServeMux(), +func (admin AdminConfig) newAdminHandler(addr NetworkAddress, remote bool) adminHandler { +	muxWrap := adminHandler{mux: http.NewServeMux()} + +	// secure the local or remote endpoint respectively +	if remote { +		muxWrap.remoteControl = admin.Remote +	} else { +		muxWrap.enforceHost = !addr.isWildcardInterface() +		muxWrap.allowedOrigins = admin.allowedOrigins(addr)  	}  	addRouteWithMetrics := func(pattern string, handlerLabel string, h http.Handler) { @@ -197,18 +277,18 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []string {  	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 { +// replaceLocalAdminServer replaces the running local 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 replaceLocalAdminServer(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 +	oldAdminServer := localAdminServer  	defer func() {  		// do the shutdown asynchronously so that any  		// current API request gets a response; this @@ -236,19 +316,20 @@ func replaceAdmin(cfg *Config) error {  	}  	// extract a singular listener address -	addr, err := adminConfig.listenAddr() +	addr, err := parseAdminListenAddr(adminConfig.Listen, DefaultAdminListen)  	if err != nil {  		return err  	} -	handler := adminConfig.newAdminHandler(addr) +	handler := adminConfig.newAdminHandler(addr, false)  	ln, err := Listen(addr.Network, addr.JoinHostPort(0))  	if err != nil {  		return err  	} -	adminServer = &http.Server{ +	localAdminServer = &http.Server{ +		Addr:              addr.String(), // for logging purposes only  		Handler:           handler,  		ReadTimeout:       10 * time.Second,  		ReadHeaderTimeout: 5 * time.Second, @@ -258,7 +339,7 @@ func replaceAdmin(cfg *Config) error {  	adminLogger := Log().Named("admin")  	go func() { -		if err := adminServer.Serve(ln); !errors.Is(err, http.ErrServerClosed) { +		if err := localAdminServer.Serve(ln); !errors.Is(err, http.ErrServerClosed) {  			adminLogger.Error("admin server shutdown for unknown reason", zap.Error(err))  		}  	}() @@ -276,6 +357,252 @@ func replaceAdmin(cfg *Config) error {  	return nil  } +// manageIdentity sets up automated identity management for this server. +func manageIdentity(ctx Context, cfg *Config) error { +	if cfg == nil || cfg.Admin == nil || cfg.Admin.Identity == nil { +		return nil +	} + +	oldIdentityCertCache := identityCertCache +	if oldIdentityCertCache != nil { +		defer oldIdentityCertCache.Stop() +	} + +	// set default issuers; this is pretty hacky because we can't +	// import the caddytls package -- but it works +	if cfg.Admin.Identity.IssuersRaw == nil { +		cfg.Admin.Identity.IssuersRaw = []json.RawMessage{ +			json.RawMessage(`{"module": "zerossl"}`), +			json.RawMessage(`{"module": "acme"}`), +		} +	} + +	// load and provision issuer modules +	if cfg.Admin.Identity.IssuersRaw != nil { +		val, err := ctx.LoadModule(cfg.Admin.Identity, "IssuersRaw") +		if err != nil { +			return fmt.Errorf("loading identity issuer modules: %s", err) +		} +		for _, issVal := range val.([]interface{}) { +			cfg.Admin.Identity.issuers = append(cfg.Admin.Identity.issuers, issVal.(certmagic.Issuer)) +		} +	} + +	logger := Log().Named("admin.identity") +	cmCfg := cfg.Admin.Identity.certmagicConfig(logger) + +	// issuers have circular dependencies with the configs because, +	// as explained in the caddytls package, they need access to the +	// correct storage and cache to solve ACME challenges +	for _, issuer := range cfg.Admin.Identity.issuers { +		// avoid import cycle with caddytls package, so manually duplicate the interface here, yuck +		if annoying, ok := issuer.(interface{ SetConfig(cfg *certmagic.Config) }); ok { +			annoying.SetConfig(cmCfg) +		} +	} + +	// obtain and renew server identity certificate(s) +	return cmCfg.ManageAsync(ctx, cfg.Admin.Identity.Identifiers) +} + +// replaceRemoteAdminServer replaces the running remote admin server +// according to the relevant configuration in cfg. It stops any previous +// remote admin server and only starts a new one if configured. +func replaceRemoteAdminServer(ctx Context, cfg *Config) error { +	if cfg == nil { +		return nil +	} + +	remoteLogger := Log().Named("admin.remote") + +	oldAdminServer := remoteAdminServer +	defer func() { +		if oldAdminServer != nil { +			go func(oldAdminServer *http.Server) { +				err := stopAdminServer(oldAdminServer) +				if err != nil { +					Log().Named("admin").Error("stopping current secure admin endpoint", zap.Error(err)) +				} +			}(oldAdminServer) +		} +	}() + +	if cfg.Admin == nil || cfg.Admin.Remote == nil { +		return nil +	} + +	addr, err := parseAdminListenAddr(cfg.Admin.Remote.Listen, DefaultRemoteAdminListen) +	if err != nil { +		return err +	} + +	// make the HTTP handler but disable Host/Origin enforcement +	// because we are using TLS authentication instead +	handler := cfg.Admin.newAdminHandler(addr, true) + +	// create client certificate pool for TLS mutual auth, and extract public keys +	// so that we can enforce access controls at the application layer +	clientCertPool := x509.NewCertPool() +	for i, accessControl := range cfg.Admin.Remote.AccessControl { +		for j, certBase64 := range accessControl.PublicKeys { +			cert, err := decodeBase64DERCert(certBase64) +			if err != nil { +				return fmt.Errorf("access control %d public key %d: parsing base64 certificate DER: %v", i, j, err) +			} +			accessControl.publicKeys = append(accessControl.publicKeys, cert.PublicKey) +			clientCertPool.AddCert(cert) +		} +	} + +	// create TLS config that will enforce mutual authentication +	cmCfg := cfg.Admin.Identity.certmagicConfig(remoteLogger) +	tlsConfig := cmCfg.TLSConfig() +	tlsConfig.NextProtos = nil // this server does not solve ACME challenges +	tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert +	tlsConfig.ClientCAs = clientCertPool + +	// convert logger to stdlib so it can be used by HTTP server +	serverLogger, err := zap.NewStdLogAt(remoteLogger, zap.DebugLevel) +	if err != nil { +		return err +	} + +	// create secure HTTP server +	remoteAdminServer = &http.Server{ +		Addr:              addr.String(), // for logging purposes only +		Handler:           handler, +		TLSConfig:         tlsConfig, +		ReadTimeout:       10 * time.Second, +		ReadHeaderTimeout: 5 * time.Second, +		IdleTimeout:       60 * time.Second, +		MaxHeaderBytes:    1024 * 64, +		ErrorLog:          serverLogger, +	} + +	// start listener +	ln, err := Listen(addr.Network, addr.JoinHostPort(0)) +	if err != nil { +		return err +	} +	ln = tls.NewListener(ln, tlsConfig) + +	go func() { +		if err := remoteAdminServer.Serve(ln); !errors.Is(err, http.ErrServerClosed) { +			remoteLogger.Error("admin remote server shutdown for unknown reason", zap.Error(err)) +		} +	}() + +	remoteLogger.Info("secure admin remote control endpoint started", +		zap.String("address", addr.String())) + +	return nil +} + +func (ident *IdentityConfig) certmagicConfig(logger *zap.Logger) *certmagic.Config { +	if ident == nil { +		// user might not have configured identity; that's OK, we can still make a +		// certmagic config, although it'll be mostly useless for remote management +		ident = new(IdentityConfig) +	} +	cmCfg := &certmagic.Config{ +		Storage: DefaultStorage, // do not act as part of a cluster (this is for the server's local identity) +		Logger:  logger, +		Issuers: ident.issuers, +	} +	if identityCertCache == nil { +		identityCertCache = certmagic.NewCache(certmagic.CacheOptions{ +			GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) { +				return cmCfg, nil +			}, +		}) +	} +	return certmagic.New(identityCertCache, *cmCfg) +} + +// IdentityCredentials returns this instance's configured, managed identity credentials +// that can be used in TLS client authentication. +func (ctx Context) IdentityCredentials(logger *zap.Logger) ([]tls.Certificate, error) { +	if ctx.cfg == nil || ctx.cfg.Admin == nil || ctx.cfg.Admin.Identity == nil { +		return nil, fmt.Errorf("no server identity configured") +	} +	ident := ctx.cfg.Admin.Identity +	if len(ident.Identifiers) == 0 { +		return nil, fmt.Errorf("no identifiers configured") +	} +	if logger == nil { +		logger = Log() +	} +	magic := ident.certmagicConfig(logger) +	return magic.ClientCredentials(ctx, ident.Identifiers) +} + +// enforceAccessControls enforces application-layer access controls for r based on remote. +// It expects that the TLS server has already established at least one verified chain of +// trust, and then looks for a matching, authorized public key that is allowed to access +// the defined path(s) using the defined method(s). +func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error { +	for _, chain := range r.TLS.VerifiedChains { +		for _, peerCert := range chain { +			for _, adminAccess := range remote.AccessControl { +				for _, allowedKey := range adminAccess.publicKeys { +					// see if we found a matching public key; the TLS server already verified the chain +					// so we know the client possesses the associated private key; this handy interface +					// doesn't appear to be defined anywhere in the std lib, but was implemented here: +					// https://github.com/golang/go/commit/b5f2c0f50297fa5cd14af668ddd7fd923626cf8c +					comparer, ok := peerCert.PublicKey.(interface{ Equal(crypto.PublicKey) bool }) +					if !ok || !comparer.Equal(allowedKey) { +						continue +					} + +					// key recognized; make sure its HTTP request is permitted +					for _, accessPerm := range adminAccess.Permissions { +						// verify method +						methodFound := accessPerm.Methods == nil +						for _, method := range accessPerm.Methods { +							if method == r.Method { +								methodFound = true +								break +							} +						} +						if !methodFound { +							return APIError{ +								HTTPStatus: http.StatusForbidden, +								Message:    "not authorized to use this method", +							} +						} + +						// verify path +						pathFound := accessPerm.Paths == nil +						for _, allowedPath := range accessPerm.Paths { +							if strings.HasPrefix(r.URL.Path, allowedPath) { +								pathFound = true +								break +							} +						} +						if !pathFound { +							return APIError{ +								HTTPStatus: http.StatusForbidden, +								Message:    "not authorized to access this path", +							} +						} +					} + +					// public key authorized, method and path allowed +					return nil +				} +			} +		} +	} + +	// in theory, this should never happen; with an unverified chain, the TLS server +	// should not accept the connection in the first place, and the acceptable cert +	// pool is configured using the same list of public keys we verify against +	return APIError{ +		HTTPStatus: http.StatusUnauthorized, +		Message:    "client identity not authorized", +	} +} +  func stopAdminServer(srv *http.Server) error {  	if srv == nil {  		return fmt.Errorf("no admin server") @@ -286,7 +613,7 @@ func stopAdminServer(srv *http.Server) error {  	if err != nil {  		return fmt.Errorf("shutting down admin server: %v", err)  	} -	Log().Named("admin").Info("stopped previous server") +	Log().Named("admin").Info("stopped previous server", zap.String("address", srv.Addr))  	return nil  } @@ -302,10 +629,15 @@ type AdminRoute struct {  }  type adminHandler struct { +	mux *http.ServeMux + +	// security for local/plaintext) endpoint, on by default  	enforceOrigin  bool  	enforceHost    bool  	allowedOrigins []string -	mux            *http.ServeMux + +	// security for remote/encrypted endpoint +	remoteControl *RemoteAdmin  }  // ServeHTTP is the external entry point for API requests. @@ -318,6 +650,12 @@ func (h adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {  		zap.String("remote_addr", r.RemoteAddr),  		zap.Reflect("headers", r.Header),  	) +	if r.TLS != nil { +		log = log.With( +			zap.Bool("secure", true), +			zap.Int("verified_chains", len(r.TLS.VerifiedChains)), +		) +	}  	if r.RequestURI == "/metrics" {  		log.Debug("received request")  	} else { @@ -330,6 +668,14 @@ func (h adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {  // 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.remoteControl != nil { +		// enforce access controls on secure endpoint +		if err := h.remoteControl.enforceAccessControls(r); err != nil { +			h.handleError(w, r, err) +			return +		} +	} +  	if strings.Contains(r.Header.Get("Upgrade"), "websocket") {  		// I've never been able demonstrate a vulnerability myself, but apparently  		// WebSocket connections originating from browsers aren't subject to CORS @@ -363,8 +709,6 @@ func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {  		w.Header().Set("Access-Control-Allow-Origin", origin)  	} -	// TODO: authentication & authorization, if configured -  	h.mux.ServeHTTP(w, r)  } @@ -372,20 +716,16 @@ func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err er  	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, +			HTTPStatus: http.StatusInternalServerError, +			Err:        err,  		}  	} -	if apiErr.Code == 0 { -		apiErr.Code = http.StatusInternalServerError +	if apiErr.HTTPStatus == 0 { +		apiErr.HTTPStatus = http.StatusInternalServerError  	}  	if apiErr.Message == "" && apiErr.Err != nil {  		apiErr.Message = apiErr.Err.Error() @@ -393,11 +733,11 @@ func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err er  	Log().Named("admin.api").Error("request error",  		zap.Error(err), -		zap.Int("status_code", apiErr.Code), +		zap.Int("status_code", apiErr.HTTPStatus),  	)  	w.Header().Set("Content-Type", "application/json") -	w.WriteHeader(apiErr.Code) +	w.WriteHeader(apiErr.HTTPStatus)  	encErr := json.NewEncoder(w).Encode(apiErr)  	if encErr != nil {  		Log().Named("admin.api").Error("failed to encode error response", zap.Error(encErr)) @@ -418,8 +758,8 @@ func (h adminHandler) checkHost(r *http.Request) error {  	}  	if !allowed {  		return APIError{ -			Code: http.StatusForbidden, -			Err:  fmt.Errorf("host not allowed: %s", r.Host), +			HTTPStatus: http.StatusForbidden, +			Err:        fmt.Errorf("host not allowed: %s", r.Host),  		}  	}  	return nil @@ -433,14 +773,14 @@ 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"), +			HTTPStatus: 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), +			HTTPStatus: http.StatusForbidden, +			Err:        fmt.Errorf("client is not allowed to access from origin %s", origin),  		}  	}  	return origin, nil @@ -480,7 +820,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {  		err := readConfig(r.URL.Path, w)  		if err != nil { -			return APIError{Code: http.StatusBadRequest, Err: err} +			return APIError{HTTPStatus: http.StatusBadRequest, Err: err}  		}  		return nil @@ -495,8 +835,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {  		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), +					HTTPStatus: http.StatusBadRequest, +					Err:        fmt.Errorf("unacceptable content-type: %v; 'application/json' required", ct),  				}  			} @@ -507,8 +847,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {  			_, err := io.Copy(buf, r.Body)  			if err != nil {  				return APIError{ -					Code: http.StatusBadRequest, -					Err:  fmt.Errorf("reading request body: %v", err), +					HTTPStatus: http.StatusBadRequest, +					Err:        fmt.Errorf("reading request body: %v", err),  				}  			}  			body = buf.Bytes() @@ -523,8 +863,8 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {  	default:  		return APIError{ -			Code: http.StatusMethodNotAllowed, -			Err:  fmt.Errorf("method %s not allowed", r.Method), +			HTTPStatus: http.StatusMethodNotAllowed, +			Err:        fmt.Errorf("method %s not allowed", r.Method),  		}  	} @@ -555,46 +895,17 @@ func handleConfigID(w http.ResponseWriter, r *http.Request) error {  	parts = append([]string{expanded}, parts[3:]...)  	r.URL.Path = path.Join(parts...) -	return ErrInternalRedir -} - -func handleStop(w http.ResponseWriter, r *http.Request) error { -	err := handleUnload(w, r) -	if err != nil { -		Log().Named("admin.api").Error("unload error", zap.Error(err)) -	} -	if adminServer != nil { -		// use goroutine so that we can finish responding to API request -		go func() { -			err := stopAdminServer(adminServer) -			var exitCode int -			if err != nil { -				exitCode = ExitCodeFailedQuit -				Log().Named("admin.api").Error("failed to stop admin server gracefully", zap.Error(err)) -			} -			Log().Named("admin.api").Info("stopping now, bye!! 👋") -			os.Exit(exitCode) -		}() -	}  	return nil  } -// handleUnload stops the current configuration that is running. -// Note that doing this can also be accomplished with DELETE /config/ -// but we leave this function because handleStop uses it. -func handleUnload(w http.ResponseWriter, r *http.Request) error { +func handleStop(w http.ResponseWriter, r *http.Request) error {  	if r.Method != http.MethodPost {  		return APIError{ -			Code: http.StatusMethodNotAllowed, -			Err:  fmt.Errorf("method not allowed"), +			HTTPStatus: http.StatusMethodNotAllowed, +			Err:        fmt.Errorf("method not allowed"),  		}  	} -	Log().Named("admin.api").Info("unloading") -	if err := stopAndCleanup(); err != nil { -		Log().Named("admin.api").Error("error unloading", zap.Error(err)) -	} else { -		Log().Named("admin.api").Info("unloading completed") -	} +	exitProcess(Log().Named("admin.api"))  	return nil  } @@ -806,9 +1117,9 @@ func (f AdminHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) erro  // 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"` +	HTTPStatus int    `json:"-"` +	Err        error  `json:"-"` +	Message    string `json:"error"`  }  func (e APIError) Error() string { @@ -818,20 +1129,44 @@ func (e APIError) Error() string {  	return e.Message  } +// parseAdminListenAddr extracts a singular listen address from either addr +// or defaultAddr, returning the network and the address of the listener. +func parseAdminListenAddr(addr string, defaultAddr string) (NetworkAddress, error) { +	input := addr +	if input == "" { +		input = defaultAddr +	} +	listenAddr, err := ParseNetworkAddress(input) +	if err != nil { +		return NetworkAddress{}, fmt.Errorf("parsing listener address: %v", err) +	} +	if listenAddr.PortRangeSize() != 1 { +		return NetworkAddress{}, fmt.Errorf("must be exactly one listener address; cannot listen on: %s", listenAddr) +	} +	return listenAddr, nil +} + +// decodeBase64DERCert base64-decodes, then DER-decodes, certStr. +func decodeBase64DERCert(certStr string) (*x509.Certificate, error) { +	derBytes, err := base64.StdEncoding.DecodeString(certStr) +	if err != nil { +		return nil, err +	} +	return x509.ParseCertificate(derBytes) +} +  var ( -	// DefaultAdminListen is the address for the admin +	// DefaultAdminListen is the address for the local 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") +	// DefaultRemoteAdminListen is the address for the remote +	// (TLS-authenticated) admin listener, if enabled and not +	// specified otherwise. +	DefaultRemoteAdminListen = ":2021"  	// DefaultAdminConfig is the default configuration -	// for the administration endpoint. +	// for the local administration endpoint.  	DefaultAdminConfig = &AdminConfig{  		Listen: DefaultAdminListen,  	} @@ -869,4 +1204,8 @@ var bufPool = sync.Pool{  	},  } -var adminServer *http.Server +// keep a reference to admin endpoint singletons while they're active +var ( +	localAdminServer, remoteAdminServer *http.Server +	identityCertCache                   *certmagic.Cache +) | 
