c8837f2010
* Add ACME health checks to pki health-check CLI - Verify we have the required header values listed within allowed_response_headers: 'Replay-Nonce', 'Link', 'Location' - Make sure the local cluster config path variable contains an URL with an https scheme * Split ACME health checks into two separate verifications - Promote ACME usage through the enable_acme_issuance check, if ACME is disabled currently - If ACME is enabled verify that we have a valid 'path' field within local cluster configuration as well as the proper response headers allowed. - Factor out response header verifications into a separate check mainly to work around possible permission issues. * Only recommend enabling ACME on mounts with intermediate issuers * Attempt to connect to the ACME directory based on the cluster path variable - Final health check is to attempt to connect to the ACME directory based on the cluster local 'path' value. Only if we successfully connect do we say ACME is healthy. * Fix broken unit test
238 lines
6.1 KiB
Go
238 lines
6.1 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package healthcheck
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
|
|
"golang.org/x/crypto/acme"
|
|
)
|
|
|
|
type EnableAcmeIssuance struct {
|
|
Enabled bool
|
|
UnsupportedVersion bool
|
|
|
|
AcmeConfigFetcher *PathFetch
|
|
ClusterConfigFetcher *PathFetch
|
|
TotalIssuers int
|
|
RootIssuers int
|
|
}
|
|
|
|
func NewEnableAcmeIssuance() Check {
|
|
return &EnableAcmeIssuance{}
|
|
}
|
|
|
|
func (h *EnableAcmeIssuance) Name() string {
|
|
return "enable_acme_issuance"
|
|
}
|
|
|
|
func (h *EnableAcmeIssuance) IsEnabled() bool {
|
|
return h.Enabled
|
|
}
|
|
|
|
func (h *EnableAcmeIssuance) DefaultConfig() map[string]interface{} {
|
|
return map[string]interface{}{}
|
|
}
|
|
|
|
func (h *EnableAcmeIssuance) LoadConfig(config map[string]interface{}) error {
|
|
enabled, err := parseutil.ParseBool(config["enabled"])
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing %v.enabled: %w", h.Name(), err)
|
|
}
|
|
h.Enabled = enabled
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *EnableAcmeIssuance) FetchResources(e *Executor) error {
|
|
var err error
|
|
h.AcmeConfigFetcher, err = e.FetchIfNotFetched(logical.ReadOperation, "/{{mount}}/config/acme")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if h.AcmeConfigFetcher.IsUnsupportedPathError() {
|
|
h.UnsupportedVersion = true
|
|
}
|
|
|
|
h.ClusterConfigFetcher, err = e.FetchIfNotFetched(logical.ReadOperation, "/{{mount}}/config/cluster")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if h.ClusterConfigFetcher.IsUnsupportedPathError() {
|
|
h.UnsupportedVersion = true
|
|
}
|
|
|
|
h.TotalIssuers, h.RootIssuers, err = doesMountContainOnlyRootIssuers(e)
|
|
|
|
return nil
|
|
}
|
|
|
|
func doesMountContainOnlyRootIssuers(e *Executor) (int, int, error) {
|
|
exit, _, issuers, err := pkiFetchIssuersList(e, func() {})
|
|
if exit || err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
totalIssuers := 0
|
|
rootIssuers := 0
|
|
|
|
for _, issuer := range issuers {
|
|
skip, _, cert, err := pkiFetchIssuer(e, issuer, func() {})
|
|
|
|
if skip || err != nil {
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
continue
|
|
}
|
|
totalIssuers++
|
|
|
|
if !bytes.Equal(cert.RawSubject, cert.RawIssuer) {
|
|
continue
|
|
}
|
|
if err := cert.CheckSignatureFrom(cert); err != nil {
|
|
continue
|
|
}
|
|
rootIssuers++
|
|
}
|
|
|
|
return totalIssuers, rootIssuers, nil
|
|
}
|
|
|
|
func isAcmeEnabled(fetcher *PathFetch) (bool, error) {
|
|
isEnabledRaw, ok := fetcher.Secret.Data["enabled"]
|
|
if !ok {
|
|
return false, fmt.Errorf("enabled configuration field missing from acme config")
|
|
}
|
|
|
|
parseBool, err := parseutil.ParseBool(isEnabledRaw)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed parsing 'enabled' field from ACME config: %w", err)
|
|
}
|
|
|
|
return parseBool, nil
|
|
}
|
|
|
|
func verifyLocalPathUrl(h *EnableAcmeIssuance) error {
|
|
localPathRaw, ok := h.ClusterConfigFetcher.Secret.Data["path"]
|
|
if !ok {
|
|
return fmt.Errorf("'path' field missing from config")
|
|
}
|
|
|
|
localPath, err := parseutil.ParseString(localPathRaw)
|
|
if err != nil {
|
|
return fmt.Errorf("failed converting 'path' field from local config: %w", err)
|
|
}
|
|
|
|
if localPath == "" {
|
|
return fmt.Errorf("'path' field not configured within /{{mount}}/config/cluster")
|
|
}
|
|
|
|
parsedUrl, err := url.Parse(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse URL from path config: %v: %w", localPathRaw, err)
|
|
}
|
|
|
|
if parsedUrl.Scheme != "https" {
|
|
return fmt.Errorf("the configured 'path' field in /{{mount}}/config/cluster was not using an https scheme")
|
|
}
|
|
|
|
// Avoid issues with SSL certificates for this check, we just want to validate that we would
|
|
// hit an ACME server with the path they specified in configuration
|
|
tr := &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}
|
|
client := &http.Client{Transport: tr}
|
|
acmeDirectoryUrl := parsedUrl.JoinPath("/acme/", "directory")
|
|
acmeClient := acme.Client{HTTPClient: client, DirectoryURL: acmeDirectoryUrl.String()}
|
|
_, err = acmeClient.Discover(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("using configured 'path' field ('%s') in /{{mount}}/config/cluster failed to reach the ACME"+
|
|
" directory: %s: %w", parsedUrl.String(), acmeDirectoryUrl.String(), err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *EnableAcmeIssuance) Evaluate(e *Executor) (results []*Result, err error) {
|
|
if h.UnsupportedVersion {
|
|
ret := Result{
|
|
Status: ResultInvalidVersion,
|
|
Endpoint: h.AcmeConfigFetcher.Path,
|
|
Message: "This health check requires Vault 1.14+ but an earlier version of Vault Server was contacted, preventing this health check from running.",
|
|
}
|
|
return []*Result{&ret}, nil
|
|
}
|
|
|
|
if h.AcmeConfigFetcher.IsSecretPermissionsError() {
|
|
msg := "Without this information, this health check is unable to function."
|
|
return craftInsufficientPermissionResult(e, h.AcmeConfigFetcher.Path, msg), nil
|
|
}
|
|
|
|
acmeEnabled, err := isAcmeEnabled(h.AcmeConfigFetcher)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !acmeEnabled {
|
|
if h.TotalIssuers == 0 {
|
|
ret := Result{
|
|
Status: ResultNotApplicable,
|
|
Endpoint: h.AcmeConfigFetcher.Path,
|
|
Message: "No issuers in mount, ACME is not required.",
|
|
}
|
|
return []*Result{&ret}, nil
|
|
}
|
|
|
|
if h.TotalIssuers == h.RootIssuers {
|
|
ret := Result{
|
|
Status: ResultNotApplicable,
|
|
Endpoint: h.AcmeConfigFetcher.Path,
|
|
Message: "Mount contains only root issuers, ACME is not required.",
|
|
}
|
|
return []*Result{&ret}, nil
|
|
}
|
|
|
|
ret := Result{
|
|
Status: ResultInformational,
|
|
Endpoint: h.AcmeConfigFetcher.Path,
|
|
Message: "Consider enabling ACME support to support a self-rotating PKI infrastructure.",
|
|
}
|
|
return []*Result{&ret}, nil
|
|
}
|
|
|
|
if h.ClusterConfigFetcher.IsSecretPermissionsError() {
|
|
msg := "Without this information, this health check is unable to function."
|
|
return craftInsufficientPermissionResult(e, h.ClusterConfigFetcher.Path, msg), nil
|
|
}
|
|
|
|
localPathIssue := verifyLocalPathUrl(h)
|
|
|
|
if localPathIssue != nil {
|
|
ret := Result{
|
|
Status: ResultWarning,
|
|
Endpoint: h.ClusterConfigFetcher.Path,
|
|
Message: "ACME enabled in config but not functional: " + localPathIssue.Error(),
|
|
}
|
|
return []*Result{&ret}, nil
|
|
}
|
|
|
|
ret := Result{
|
|
Status: ResultOK,
|
|
Endpoint: h.ClusterConfigFetcher.Path,
|
|
Message: "ACME enabled and successfully connected to the ACME directory.",
|
|
}
|
|
return []*Result{&ret}, nil
|
|
}
|