blob: 986165d0e253ebaa0a0ff71277228c98f173a17f [file] [log] [blame] [edit]
// 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
}