diff --git a/admin.go b/admin.go index 891f818e..11efc683 100644 --- a/admin.go +++ b/admin.go @@ -92,6 +92,10 @@ type AdminConfig struct { // // EXPERIMENTAL: This feature is subject to change. Remote *RemoteAdmin `json:"remote,omitempty"` + + // Holds onto the routers so that we can later provision them + // if they require provisioning. + routers []AdminRouter } // ConfigSettings configures the management of configuration. @@ -190,7 +194,7 @@ type AdminPermissions struct { // 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, remote bool) adminHandler { +func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool) adminHandler { muxWrap := adminHandler{mux: http.NewServeMux()} // secure the local or remote endpoint respectively @@ -250,11 +254,32 @@ func (admin AdminConfig) newAdminHandler(addr NetworkAddress, remote bool) admin for _, route := range router.Routes() { addRoute(route.Pattern, handlerLabel, route.Handler) } + admin.routers = append(admin.routers, router) } return muxWrap } +// provisionAdminRouters provisions all the router modules +// in the admin.api namespace that need provisioning. +func (admin AdminConfig) provisionAdminRouters(ctx Context) error { + for _, router := range admin.routers { + provisioner, ok := router.(Provisioner) + if !ok { + continue + } + + err := provisioner.Provision(ctx) + if err != nil { + return err + } + } + + // We no longer need the routers once provisioned, allow for GC + admin.routers = nil + return nil +} + // 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 @@ -332,25 +357,26 @@ func replaceLocalAdminServer(cfg *Config) error { } }() - // always get a valid admin config - adminConfig := DefaultAdminConfig - if cfg != nil && cfg.Admin != nil { - adminConfig = cfg.Admin + // set a default if admin wasn't otherwise configured + if cfg.Admin == nil { + cfg.Admin = &AdminConfig{ + Listen: DefaultAdminListen, + } } // if new admin endpoint is to be disabled, we're done - if adminConfig.Disabled { + if cfg.Admin.Disabled { Log().Named("admin").Warn("admin endpoint disabled") return nil } // extract a singular listener address - addr, err := parseAdminListenAddr(adminConfig.Listen, DefaultAdminListen) + addr, err := parseAdminListenAddr(cfg.Admin.Listen, DefaultAdminListen) if err != nil { return err } - handler := adminConfig.newAdminHandler(addr, false) + handler := cfg.Admin.newAdminHandler(addr, false) ln, err := Listen(addr.Network, addr.JoinHostPort(0)) if err != nil { @@ -380,7 +406,7 @@ func replaceLocalAdminServer(cfg *Config) error { adminLogger.Info("admin endpoint started", zap.String("address", addr.String()), - zap.Bool("enforce_origin", adminConfig.EnforceOrigin), + zap.Bool("enforce_origin", cfg.Admin.EnforceOrigin), zap.Array("origins", loggableURLArray(handler.allowedOrigins))) if !handler.enforceHost { @@ -1244,12 +1270,6 @@ var ( // (TLS-authenticated) admin listener, if enabled and not // specified otherwise. DefaultRemoteAdminListen = ":2021" - - // DefaultAdminConfig is the default configuration - // for the local administration endpoint. - DefaultAdminConfig = &AdminConfig{ - Listen: DefaultAdminListen, - } ) // PIDFile writes a pidfile to the file at filename. It diff --git a/caddy.go b/caddy.go index 127484d4..36439240 100644 --- a/caddy.go +++ b/caddy.go @@ -427,6 +427,13 @@ func run(newCfg *Config, start bool) error { return nil } + // Provision any admin routers which may need to access + // some of the other apps at runtime + err = newCfg.Admin.provisionAdminRouters(ctx) + if err != nil { + return err + } + // Start err = func() error { var started []string diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go index ec56ab9c..d308aeb8 100644 --- a/cmd/commandfuncs.go +++ b/cmd/commandfuncs.go @@ -202,7 +202,7 @@ func cmdRun(fl Flags) (int, error) { // we don't use 'else' here since this value might have been changed in 'if' block; i.e. not mutually exclusive var configFile string if !runCmdResumeFlag { - config, configFile, err = loadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag) + config, configFile, err = LoadConfig(runCmdConfigFlag, runCmdConfigAdapterFlag) if err != nil { return caddy.ExitCodeFailedStartup, err } @@ -275,25 +275,33 @@ func cmdRun(fl Flags) (int, error) { } func cmdStop(fl Flags) (int, error) { - stopCmdAddrFlag := fl.String("address") + addrFlag := fl.String("address") + configFlag := fl.String("config") + configAdapterFlag := fl.String("adapter") - err := apiRequest(stopCmdAddrFlag, http.MethodPost, "/stop", nil, nil) + adminAddr, err := DetermineAdminAPIAddress(addrFlag, configFlag, configAdapterFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err) + } + + resp, err := AdminAPIRequest(adminAddr, http.MethodPost, "/stop", nil, nil) if err != nil { caddy.Log().Warn("failed using API to stop instance", zap.Error(err)) return caddy.ExitCodeFailedStartup, err } + defer resp.Body.Close() return caddy.ExitCodeSuccess, nil } func cmdReload(fl Flags) (int, error) { - reloadCmdConfigFlag := fl.String("config") - reloadCmdConfigAdapterFlag := fl.String("adapter") - reloadCmdAddrFlag := fl.String("address") - reloadCmdForceFlag := fl.Bool("force") + configFlag := fl.String("config") + configAdapterFlag := fl.String("adapter") + addrFlag := fl.String("address") + forceFlag := fl.Bool("force") // get the config in caddy's native format - config, configFile, err := loadConfig(reloadCmdConfigFlag, reloadCmdConfigAdapterFlag) + config, configFile, err := LoadConfig(configFlag, configAdapterFlag) if err != nil { return caddy.ExitCodeFailedStartup, err } @@ -301,30 +309,22 @@ func cmdReload(fl Flags) (int, error) { return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load") } - // get the address of the admin listener; use flag if specified - adminAddr := reloadCmdAddrFlag - if adminAddr == "" && len(config) > 0 { - var tmpStruct struct { - Admin caddy.AdminConfig `json:"admin"` - } - err = json.Unmarshal(config, &tmpStruct) - if err != nil { - return caddy.ExitCodeFailedStartup, - fmt.Errorf("unmarshaling admin listener address from config: %v", err) - } - adminAddr = tmpStruct.Admin.Listen + adminAddr, err := DetermineAdminAPIAddress(addrFlag, configFlag, configAdapterFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err) } // optionally force a config reload headers := make(http.Header) - if reloadCmdForceFlag { + if forceFlag { headers.Set("Cache-Control", "must-revalidate") } - err = apiRequest(adminAddr, http.MethodPost, "/load", headers, bytes.NewReader(config)) + resp, err := AdminAPIRequest(adminAddr, http.MethodPost, "/load", headers, bytes.NewReader(config)) if err != nil { return caddy.ExitCodeFailedStartup, fmt.Errorf("sending configuration to instance: %v", err) } + defer resp.Body.Close() return caddy.ExitCodeSuccess, nil } @@ -518,7 +518,7 @@ func cmdValidateConfig(fl Flags) (int, error) { validateCmdConfigFlag := fl.String("config") validateCmdAdapterFlag := fl.String("adapter") - input, _, err := loadConfig(validateCmdConfigFlag, validateCmdAdapterFlag) + input, _, err := LoadConfig(validateCmdConfigFlag, validateCmdAdapterFlag) if err != nil { return caddy.ExitCodeFailedStartup, err } @@ -640,17 +640,15 @@ commands: return caddy.ExitCodeSuccess, nil } -// apiRequest makes an API request to the endpoint adminAddr with the -// given HTTP method and request URI. If body is non-nil, it will be -// assumed to be Content-Type application/json. -func apiRequest(adminAddr, method, uri string, headers http.Header, body io.Reader) error { - // parse the admin address - if adminAddr == "" { - adminAddr = caddy.DefaultAdminListen - } +// AdminAPIRequest makes an API request according to the CLI flags given, +// with the given HTTP method and request URI. If body is non-nil, it will +// be assumed to be Content-Type application/json. The caller should close +// the response body. Should only be used by Caddy CLI commands which +// need to interact with a running instance of Caddy via the admin API. +func AdminAPIRequest(adminAddr, method, uri string, headers http.Header, body io.Reader) (*http.Response, error) { parsedAddr, err := caddy.ParseNetworkAddress(adminAddr) if err != nil || parsedAddr.PortRangeSize() > 1 { - return fmt.Errorf("invalid admin address %s: %v", adminAddr, err) + return nil, fmt.Errorf("invalid admin address %s: %v", adminAddr, err) } origin := parsedAddr.JoinHostPort(0) if parsedAddr.IsUnixNetwork() { @@ -660,7 +658,7 @@ func apiRequest(adminAddr, method, uri string, headers http.Header, body io.Read // form the request req, err := http.NewRequest(method, "http://"+origin+uri, body) if err != nil { - return fmt.Errorf("making request: %v", err) + return nil, fmt.Errorf("making request: %v", err) } if parsedAddr.IsUnixNetwork() { // When listening on a unix socket, the admin endpoint doesn't @@ -700,20 +698,58 @@ func apiRequest(adminAddr, method, uri string, headers http.Header, body io.Read resp, err := client.Do(req) if err != nil { - return fmt.Errorf("performing request: %v", err) + return nil, fmt.Errorf("performing request: %v", err) } - defer resp.Body.Close() // if it didn't work, let the user know if resp.StatusCode >= 400 { respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1024*10)) if err != nil { - return fmt.Errorf("HTTP %d: reading error message: %v", resp.StatusCode, err) + return nil, fmt.Errorf("HTTP %d: reading error message: %v", resp.StatusCode, err) } - return fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, respBody) + return nil, fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, respBody) } - return nil + return resp, nil +} + +// DetermineAdminAPIAddress determines which admin API endpoint address should +// be used based on the inputs. By priority: if `address` is specified, then +// it is returned; if `configFile` (and `configAdapter`) are specified, then that +// config will be loaded to find the admin address; otherwise, the default +// admin listen address will be returned. +func DetermineAdminAPIAddress(address, configFile, configAdapter string) (string, error) { + // Prefer the address if specified and non-empty + if address != "" { + return address, nil + } + + // Try to load the config from file if specified, with the given adapter name + if configFile != "" { + // get the config in caddy's native format + config, loadedConfigFile, err := LoadConfig(configFile, configAdapter) + if err != nil { + return "", err + } + if loadedConfigFile == "" { + return "", fmt.Errorf("no config file to load") + } + + // get the address of the admin listener + if len(config) > 0 { + var tmpStruct struct { + Admin caddy.AdminConfig `json:"admin"` + } + err = json.Unmarshal(config, &tmpStruct) + if err != nil { + return "", fmt.Errorf("unmarshaling admin listener address from config: %v", err) + } + return tmpStruct.Admin.Listen, nil + } + } + + // Fallback to the default listen address otherwise + return caddy.DefaultAdminListen, nil } type moduleInfo struct { diff --git a/cmd/commands.go b/cmd/commands.go index 1e2c40de..0c68b7c8 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -156,16 +156,19 @@ development environment.`, RegisterCommand(Command{ Name: "stop", Func: cmdStop, + Usage: "[--address ] [--config [--adapter ]]", Short: "Gracefully stops a started Caddy process", Long: ` Stops the background Caddy process as gracefully as possible. It requires that the admin API is enabled and accessible, since it will -use the API's /stop endpoint. The address of this request can be -customized using the --address flag if it is not the default.`, +use the API's /stop endpoint. The address of this request can be customized +using the --address flag, or from the given --config, if not the default.`, Flags: func() *flag.FlagSet { fs := flag.NewFlagSet("stop", flag.ExitOnError) fs.String("address", "", "The address to use to reach the admin API endpoint, if not the default") + fs.String("config", "", "Configuration file to use to parse the admin address, if --address is not used") + fs.String("adapter", "", "Name of config adapter to apply (when --config is used)") return fs }(), }) diff --git a/cmd/main.go b/cmd/main.go index 7c33c55f..f111ba4d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -103,15 +103,15 @@ func handlePingbackConn(conn net.Conn, expect []byte) error { return nil } -// loadConfig loads the config from configFile and adapts it +// LoadConfig loads the config from configFile and adapts it // using adapterName. If adapterName is specified, configFile // must be also. If no configFile is specified, it tries // loading a default config file. The lack of a config file is // not treated as an error, but false will be returned if // there is no config available. It prints any warnings to stderr, // and returns the resulting JSON config bytes along with -// whether a config file was loaded or not. -func loadConfig(configFile, adapterName string) ([]byte, string, error) { +// the name of the loaded config file (if any). +func LoadConfig(configFile, adapterName string) ([]byte, string, error) { // specifying an adapter without a config file is ambiguous if adapterName != "" && configFile == "" { return nil, "", fmt.Errorf("cannot adapt config without config file (use --config)") @@ -262,7 +262,7 @@ func watchConfigFile(filename, adapterName string) { lastModified = info.ModTime() // load the contents of the file - config, _, err := loadConfig(filename, adapterName) + config, _, err := LoadConfig(filename, adapterName) if err != nil { logger().Error("unable to load latest config", zap.Error(err)) continue diff --git a/context.go b/context.go index a6386aa8..2a6f5142 100644 --- a/context.go +++ b/context.go @@ -423,6 +423,17 @@ func (ctx Context) App(name string) (interface{}, error) { return modVal, nil } +// AppIsConfigured returns whether an app named name has been +// configured. Can be called before calling App() to avoid +// instantiating an empty app when that's not desirable. +func (ctx Context) AppIsConfigured(name string) bool { + if _, ok := ctx.cfg.apps[name]; ok { + return true + } + appRaw := ctx.cfg.AppsRaw[name] + return appRaw != nil +} + // Storage returns the configured Caddy storage implementation. func (ctx Context) Storage() certmagic.Storage { return ctx.cfg.storage diff --git a/modules/caddypki/adminpki.go b/modules/caddypki/adminpki.go new file mode 100644 index 00000000..5933bcd9 --- /dev/null +++ b/modules/caddypki/adminpki.go @@ -0,0 +1,194 @@ +// Copyright 2020 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 caddypki + +import ( + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "strings" + + "github.com/caddyserver/caddy/v2" + "go.uber.org/zap" +) + +func init() { + caddy.RegisterModule(adminPKI{}) +} + +// adminPKI is a module that serves a PKI endpoint to retrieve +// information about the CAs being managed by Caddy. +type adminPKI struct { + ctx caddy.Context + log *zap.Logger + pkiApp *PKI +} + +// CaddyModule returns the Caddy module information. +func (adminPKI) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "admin.api.pki", + New: func() caddy.Module { return new(adminPKI) }, + } +} + +// Provision sets up the adminPKI module. +func (a *adminPKI) Provision(ctx caddy.Context) error { + a.ctx = ctx + a.log = ctx.Logger(a) + + // First check if the PKI app was configured, because + // a.ctx.App() has the side effect of instantiating + // and provisioning an app even if it wasn't configured. + pkiAppConfigured := a.ctx.AppIsConfigured("pki") + if !pkiAppConfigured { + return nil + } + + // Load the PKI app, so we can query it for information. + appModule, err := a.ctx.App("pki") + if err != nil { + return err + } + a.pkiApp = appModule.(*PKI) + + return nil +} + +// Routes returns the admin routes for the PKI app. +func (a *adminPKI) Routes() []caddy.AdminRoute { + return []caddy.AdminRoute{ + { + Pattern: adminPKICertificatesEndpoint, + Handler: caddy.AdminHandlerFunc(a.handleCertificates), + }, + } +} + +// handleCertificates returns certificate information about a particular +// CA, by its ID. If the CA ID is the default, then the CA will be +// provisioned if it has not already been. Other CA IDs will return an +// error if they have not been previously provisioned. +func (a *adminPKI) handleCertificates(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodGet { + return caddy.APIError{ + HTTPStatus: http.StatusMethodNotAllowed, + Err: fmt.Errorf("method not allowed"), + } + } + + // Prep for a JSON response + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + + idPath := r.URL.Path + + // Grab the CA ID from the request path, it should be the 4th segment + parts := strings.Split(idPath, "/") + if len(parts) < 4 || parts[3] == "" { + return caddy.APIError{ + HTTPStatus: http.StatusBadRequest, + Err: fmt.Errorf("request path is missing the CA ID"), + } + } + if parts[0] != "" || parts[1] != "pki" || parts[2] != "certificates" { + return caddy.APIError{ + HTTPStatus: http.StatusBadRequest, + Err: fmt.Errorf("malformed object path"), + } + } + id := parts[3] + + // Find the CA by ID, if PKI is configured + var ca *CA + ok := false + if a.pkiApp != nil { + ca, ok = a.pkiApp.CAs[id] + } + + // If we didn't find the CA, and PKI is not configured + // then we'll either error out if the CA ID is not the + // default. If the CA ID is the default, then we'll + // provision it, because the user probably aims to + // change their config to enable PKI immediately after + // if they actually requested the local CA ID. + if !ok { + if id != DefaultCAID { + return caddy.APIError{ + HTTPStatus: http.StatusNotFound, + Err: fmt.Errorf("no certificate authority configured with id: %s", id), + } + } + + // Provision the default CA, which generates and stores a root + // certificate in storage, if one doesn't already exist. + ca = new(CA) + err := ca.Provision(a.ctx, id, a.log) + if err != nil { + return caddy.APIError{ + HTTPStatus: http.StatusInternalServerError, + Err: fmt.Errorf("failed to provision CA %s, %w", id, err), + } + } + } + + // Convert the root certificate to PEM + rootPem := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: ca.RootCertificate().Raw, + })) + + // Convert the intermediate certificate to PEM + interPem := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: ca.IntermediateCertificate().Raw, + })) + + // Build the response + response := CAInfo{ + ID: ca.ID, + Name: ca.Name, + Root: rootPem, + Intermediate: interPem, + } + + // Encode and write the JSON response + err := enc.Encode(response) + if err != nil { + return caddy.APIError{ + HTTPStatus: http.StatusInternalServerError, + Err: err, + } + } + + return nil +} + +// CAInfo is the response from the certificates API endpoint +type CAInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Root string `json:"root"` + Intermediate string `json:"intermediate"` +} + +const adminPKICertificatesEndpoint = "/pki/certificates/" + +// Interface guards +var ( + _ caddy.AdminRouter = (*adminPKI)(nil) + _ caddy.Provisioner = (*adminPKI)(nil) +) diff --git a/modules/caddypki/command.go b/modules/caddypki/command.go index 34daefaa..fa37ab04 100644 --- a/modules/caddypki/command.go +++ b/modules/caddypki/command.go @@ -15,11 +15,13 @@ package caddypki import ( - "context" + "crypto/x509" + "encoding/json" + "encoding/pem" "flag" "fmt" + "net/http" "os" - "path/filepath" "github.com/caddyserver/caddy/v2" caddycmd "github.com/caddyserver/caddy/v2/cmd" @@ -30,69 +32,110 @@ func init() { caddycmd.RegisterCommand(caddycmd.Command{ Name: "trust", Func: cmdTrust, + Usage: "[--ca ] [--address ] [--config [--adapter ]]", Short: "Installs a CA certificate into local trust stores", Long: ` -Adds a root certificate into the local trust stores. Intended for -development environments only. +Adds a root certificate into the local trust stores. -Since Caddy will install its root certificates into the local trust -stores automatically when they are first generated, this command is -only necessary if you need to pre-install the certificates before -using them; for example, if you have elevated privileges at one -point but not later, you will want to use this command so that a -password prompt is not required later. +Caddy will attempt to install its root certificates into the local +trust stores automatically when they are first generated, but it +might fail if Caddy doesn't have the appropriate permissions to +write to the trust store. This command is necessary to pre-install +the certificates before using them, if the server process runs as an +unprivileged user (such as via systemd). -This command installs the root certificate only for Caddy's -default CA.`, +By default, this command installs the root certificate for Caddy's +default CA (i.e. 'local'). You may specify the ID of another CA +with the --ca flag. + +Also, this command will attempt to connect to the Caddy's admin API +running at '` + caddy.DefaultAdminListen + `' to fetch the root certificate. You may +explicitly specify the --address, or use the --config flag to load +the admin address from your config, if not using the default.`, + Flags: func() *flag.FlagSet { + fs := flag.NewFlagSet("trust", flag.ExitOnError) + fs.String("ca", "", "The ID of the CA to trust (defaults to 'local')") + fs.String("address", "", "Address of the administration API listener (if --config is not used)") + fs.String("config", "", "Configuration file (if --address is not used)") + fs.String("adapter", "", "Name of config adapter to apply (if --config is used)") + return fs + }(), }) caddycmd.RegisterCommand(caddycmd.Command{ Name: "untrust", Func: cmdUntrust, - Usage: "[--ca | --cert ]", + Usage: "[--cert ] | [[--ca ] [--address ] [--config [--adapter ]]]", Short: "Untrusts a locally-trusted CA certificate", Long: ` -Untrusts a root certificate from the local trust store(s). Intended -for development environments only. +Untrusts a root certificate from the local trust store(s). This command uninstalls trust; it does not necessarily delete the root certificate from trust stores entirely. Thus, repeatedly trusting and untrusting new certificates can fill up trust databases. -This command does not delete or modify certificate files. +This command does not delete or modify certificate files from Caddy's +configured storage. -Specify which certificate to untrust either by the ID of its CA with -the --ca flag, or the direct path to the certificate file with the ---cert flag. If the --ca flag is used, only the default storage paths -are assumed (i.e. using --ca flag with custom storage backends or file -paths will not work). +This command can be used in one of two ways. Either by specifying +which certificate to untrust by a direct path to the certificate +file with the --cert flag, or by fetching the root certificate for +the CA from the admin API (default behaviour). -If no flags are specified, --ca=local is assumed.`, +If the admin API is used, then the CA defaults to 'local'. You may +specify the ID of another CA with the --ca flag. By default, this +will attempt to connect to the Caddy's admin API running at +'` + caddy.DefaultAdminListen + `' to fetch the root certificate. +You may explicitly specify the --address, or use the --config flag +to load the admin address from your config, if not using the default.`, Flags: func() *flag.FlagSet { fs := flag.NewFlagSet("untrust", flag.ExitOnError) - fs.String("ca", "", "The ID of the CA to untrust") fs.String("cert", "", "The path to the CA certificate to untrust") + fs.String("ca", "", "The ID of the CA to untrust (defaults to 'local')") + fs.String("address", "", "Address of the administration API listener (if --config is not used)") + fs.String("config", "", "Configuration file (if --address is not used)") + fs.String("adapter", "", "Name of config adapter to apply (if --config is used)") return fs }(), }) } -func cmdTrust(fs caddycmd.Flags) (int, error) { - // we have to create a sort of dummy context so that - // the CA can provision itself... - ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) - defer cancel() +func cmdTrust(fl caddycmd.Flags) (int, error) { + caID := fl.String("ca") + addrFlag := fl.String("address") + configFlag := fl.String("config") + configAdapterFlag := fl.String("adapter") - // provision the CA, which generates and stores a root - // certificate if one doesn't already exist in storage - ca := CA{ - storage: caddy.DefaultStorage, + // Prepare the URI to the admin endpoint + if caID == "" { + caID = DefaultCAID } - err := ca.Provision(ctx, DefaultCAID, caddy.Log()) + + // Determine where we're sending the request to get the CA info + adminAddr, err := caddycmd.DetermineAdminAPIAddress(addrFlag, configFlag, configAdapterFlag) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err) + } + + // Fetch the root cert from the admin API + rootCert, err := rootCertFromAdmin(adminAddr, caID) if err != nil { return caddy.ExitCodeFailedStartup, err } + // Set up the CA struct; we only need to fill in the root + // because we're only using it to make use of the installRoot() + // function. Also needs a logger for warnings, and a "cert path" + // for the root cert; since we're loading from the API and we + // don't know the actual storage path via this flow, we'll just + // pass through the admin API address instead. + ca := CA{ + log: caddy.Log(), + root: rootCert, + rootCertPath: adminAddr + adminPKICertificatesEndpoint + caID, + } + + // Install the cert! err = ca.installRoot() if err != nil { return caddy.ExitCodeFailedStartup, err @@ -101,33 +144,93 @@ func cmdTrust(fs caddycmd.Flags) (int, error) { return caddy.ExitCodeSuccess, nil } -func cmdUntrust(fs caddycmd.Flags) (int, error) { - ca := fs.String("ca") - cert := fs.String("cert") +func cmdUntrust(fl caddycmd.Flags) (int, error) { + certFile := fl.String("cert") + caID := fl.String("ca") + addrFlag := fl.String("address") + configFlag := fl.String("config") + configAdapterFlag := fl.String("adapter") - if ca != "" && cert != "" { - return caddy.ExitCodeFailedStartup, fmt.Errorf("conflicting command line arguments") - } - if ca == "" && cert == "" { - ca = DefaultCAID - } - if ca != "" { - cert = filepath.Join(caddy.AppDataDir(), "pki", "authorities", ca, "root.crt") + if certFile != "" && (caID != "" || addrFlag != "" || configFlag != "") { + return caddy.ExitCodeFailedStartup, fmt.Errorf("conflicting command line arguments, cannot use --cert with other flags") } - // sanity check, make sure cert file exists first - _, err := os.Stat(cert) + // If a file was specified, try to uninstall the cert matching that file + if certFile != "" { + // Sanity check, make sure cert file exists first + _, err := os.Stat(certFile) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("accessing certificate file: %v", err) + } + + // Uninstall the file! + err = truststore.UninstallFile(certFile, + truststore.WithDebug(), + truststore.WithFirefox(), + truststore.WithJava()) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("failed to uninstall certificate file: %v", err) + } + + return caddy.ExitCodeSuccess, nil + } + + // Prepare the URI to the admin endpoint + if caID == "" { + caID = DefaultCAID + } + + // Determine where we're sending the request to get the CA info + adminAddr, err := caddycmd.DetermineAdminAPIAddress(addrFlag, configFlag, configAdapterFlag) if err != nil { - return caddy.ExitCodeFailedStartup, fmt.Errorf("accessing certificate file: %v", err) + return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err) } - err = truststore.UninstallFile(cert, - truststore.WithDebug(), - truststore.WithFirefox(), - truststore.WithJava()) + // Fetch the root cert from the admin API + rootCert, err := rootCertFromAdmin(adminAddr, caID) if err != nil { return caddy.ExitCodeFailedStartup, err } + // Uninstall the cert! + err = truststore.Uninstall(rootCert, + truststore.WithDebug(), + truststore.WithFirefox(), + truststore.WithJava()) + if err != nil { + return caddy.ExitCodeFailedStartup, fmt.Errorf("failed to uninstall certificate file: %v", err) + } + return caddy.ExitCodeSuccess, nil } + +// rootCertFromAdmin makes the API request to fetch the +func rootCertFromAdmin(adminAddr string, caID string) (*x509.Certificate, error) { + uri := adminPKICertificatesEndpoint + caID + + // Make the request to fetch the CA info + resp, err := caddycmd.AdminAPIRequest(adminAddr, http.MethodGet, uri, make(http.Header), nil) + if err != nil { + return nil, fmt.Errorf("requesting CA info: %v", err) + } + defer resp.Body.Close() + + // Decode the resposne + caInfo := new(CAInfo) + err = json.NewDecoder(resp.Body).Decode(caInfo) + if err != nil { + return nil, fmt.Errorf("failed to decode JSON response: %v", err) + } + + // Decode the root + rootBlock, _ := pem.Decode([]byte(caInfo.Root)) + if rootBlock == nil { + return nil, fmt.Errorf("failed to decode root certificate: %v", err) + } + rootCert, err := x509.ParseCertificate(rootBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse root certificate: %v", err) + } + + return rootCert, nil +} diff --git a/modules/caddypki/pki.go b/modules/caddypki/pki.go index c19bd0f2..4fd0bb50 100644 --- a/modules/caddypki/pki.go +++ b/modules/caddypki/pki.go @@ -91,7 +91,7 @@ func (p *PKI) Start() error { // install roots to trust store, if not disabled for _, ca := range p.CAs { if ca.InstallTrust != nil && !*ca.InstallTrust { - ca.log.Warn("root certificate trust store installation disabled; unconfigured clients may show warnings", + ca.log.Info("root certificate trust store installation disabled; unconfigured clients may show warnings", zap.String("path", ca.rootCertPath)) continue }