| // 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 |
| } |