| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: MPL-2.0 |
| |
| /* |
| * The healthcheck package attempts to allow generic checks of arbitrary |
| * engines, while providing a common framework with some performance |
| * efficiencies in mind. |
| * |
| * The core of this package is the Executor context; a caller would |
| * provision a set of checks, an API client, and a configuration, |
| * which the executor would use to decide which checks to execute |
| * and how. |
| * |
| * Checks are based around a series of remote paths that are fetched by |
| * the client; these are broken into two categories: static paths, which |
| * can always be fetched; and dynamic paths, which the check fetches based |
| * on earlier results. |
| * |
| * For instance, a basic PKI CA lifetime check will have static fetch against |
| * the list of CAs, and a dynamic fetch, using that earlier list, to fetch the |
| * PEMs of all CAs. |
| * |
| * This allows health checks to share data: many PKI checks will need the |
| * issuer list and so repeatedly fetching this may result in a performance |
| * impact. |
| */ |
| |
| package healthcheck |
| |
| import ( |
| "context" |
| "fmt" |
| "strings" |
| |
| "github.com/hashicorp/vault/api" |
| "github.com/hashicorp/vault/sdk/logical" |
| ) |
| |
| type Executor struct { |
| Client *api.Client |
| Mount string |
| DefaultEnabled bool |
| |
| Config map[string]map[string]interface{} |
| |
| Resources map[string]map[logical.Operation]*PathFetch |
| |
| Checkers []Check |
| } |
| |
| func NewExecutor(client *api.Client, mount string) *Executor { |
| return &Executor{ |
| Client: client, |
| DefaultEnabled: true, |
| Mount: mount, |
| Config: make(map[string]map[string]interface{}), |
| Resources: make(map[string]map[logical.Operation]*PathFetch), |
| } |
| } |
| |
| func (e *Executor) AddCheck(c Check) { |
| e.Checkers = append(e.Checkers, c) |
| } |
| |
| func (e *Executor) BuildConfig(external map[string]interface{}) error { |
| merged := e.Config |
| |
| for index, checker := range e.Checkers { |
| name := checker.Name() |
| if _, present := merged[name]; name == "" || present { |
| return fmt.Errorf("bad checker %v: name is empty or already present: %v", index, name) |
| } |
| |
| // Fetch the default configuration; if the check returns enabled |
| // status, verify it matches our expectations (in the event it should |
| // be disabled by default), otherwise, add it in. |
| config := checker.DefaultConfig() |
| enabled, present := config["enabled"] |
| if !present { |
| config["enabled"] = e.DefaultEnabled |
| } else if enabled.(bool) && !e.DefaultEnabled { |
| config["enabled"] = e.DefaultEnabled |
| } |
| |
| // Now apply any external config for this check. |
| if econfig, present := external[name]; present { |
| for param, evalue := range econfig.(map[string]interface{}) { |
| if _, ok := config[param]; !ok { |
| // Assumption: default configs have all possible |
| // configuration options. This external config has |
| // an unknown option, so we want to error out. |
| return fmt.Errorf("unknown configuration option for %v: %v", name, param) |
| } |
| |
| config[param] = evalue |
| } |
| } |
| |
| // Now apply it and save it. |
| if err := checker.LoadConfig(config); err != nil { |
| return fmt.Errorf("error saving merged config for %v: %w", name, err) |
| } |
| merged[name] = config |
| } |
| |
| return nil |
| } |
| |
| func (e *Executor) Execute() (map[string][]*Result, error) { |
| ret := make(map[string][]*Result) |
| for _, checker := range e.Checkers { |
| if !checker.IsEnabled() { |
| continue |
| } |
| |
| if err := checker.FetchResources(e); err != nil { |
| return nil, fmt.Errorf("failed to fetch resources %v: %w", checker.Name(), err) |
| } |
| |
| results, err := checker.Evaluate(e) |
| if err != nil { |
| return nil, fmt.Errorf("failed to evaluate %v: %w", checker.Name(), err) |
| } |
| |
| if results == nil { |
| results = []*Result{} |
| } |
| |
| for _, result := range results { |
| result.Endpoint = e.templatePath(result.Endpoint) |
| result.StatusDisplay = ResultStatusNameMap[result.Status] |
| } |
| |
| ret[checker.Name()] = results |
| } |
| |
| return ret, nil |
| } |
| |
| func (e *Executor) templatePath(path string) string { |
| return strings.ReplaceAll(path, "{{mount}}", e.Mount) |
| } |
| |
| func (e *Executor) FetchIfNotFetched(op logical.Operation, rawPath string) (*PathFetch, error) { |
| path := e.templatePath(rawPath) |
| |
| byOp, present := e.Resources[path] |
| if present && byOp != nil { |
| result, present := byOp[op] |
| if present && result != nil { |
| return result, result.FetchSurfaceError() |
| } |
| } |
| |
| // Must not exist in cache; create it. |
| if byOp == nil { |
| e.Resources[path] = make(map[logical.Operation]*PathFetch) |
| } |
| |
| ret := &PathFetch{ |
| Operation: op, |
| Path: path, |
| ParsedCache: make(map[string]interface{}), |
| } |
| |
| data := map[string][]string{} |
| if op == logical.ListOperation { |
| data["list"] = []string{"true"} |
| } else if op != logical.ReadOperation { |
| return nil, fmt.Errorf("unknown operation: %v on %v", op, path) |
| } |
| |
| // client.ReadRaw* methods require a manual timeout override |
| ctx, cancel := context.WithTimeout(context.Background(), e.Client.ClientTimeout()) |
| defer cancel() |
| |
| response, err := e.Client.Logical().ReadRawWithDataWithContext(ctx, path, data) |
| ret.Response = response |
| if err != nil { |
| ret.FetchError = err |
| } else { |
| // Not all secrets will parse correctly. Sometimes we really want |
| // to fetch a raw endpoint, sometimes we're run with a bad mount |
| // or missing permissions. |
| secret, secretErr := e.Client.Logical().ParseRawResponseAndCloseBody(response, err) |
| if secretErr != nil { |
| ret.SecretParseError = secretErr |
| } else { |
| ret.Secret = secret |
| } |
| } |
| |
| e.Resources[path][op] = ret |
| return ret, ret.FetchSurfaceError() |
| } |
| |
| type PathFetch struct { |
| Operation logical.Operation |
| Path string |
| Response *api.Response |
| FetchError error |
| Secret *api.Secret |
| SecretParseError error |
| ParsedCache map[string]interface{} |
| } |
| |
| func (p *PathFetch) IsOK() bool { |
| return p.FetchError == nil && p.Response != nil |
| } |
| |
| func (p *PathFetch) IsSecretOK() bool { |
| return p.IsOK() && p.SecretParseError == nil && p.Secret != nil |
| } |
| |
| func (p *PathFetch) FetchSurfaceError() error { |
| if p.IsOK() || p.IsSecretPermissionsError() || p.IsUnsupportedPathError() || p.IsMissingResource() || p.Is404NotFound() { |
| return nil |
| } |
| |
| if strings.Contains(p.FetchError.Error(), "route entry not found") { |
| return fmt.Errorf("Error making API request: was a bad mount given?\n\nOperation: %v\nPath: %v\nOriginal Error:\n%w", p.Operation, p.Path, p.FetchError) |
| } |
| |
| return p.FetchError |
| } |
| |
| func (p *PathFetch) IsSecretPermissionsError() bool { |
| return !p.IsOK() && strings.Contains(p.FetchError.Error(), "permission denied") |
| } |
| |
| func (p *PathFetch) IsUnsupportedPathError() bool { |
| return !p.IsOK() && strings.Contains(p.FetchError.Error(), "unsupported path") |
| } |
| |
| func (p *PathFetch) IsMissingResource() bool { |
| return !p.IsOK() && strings.Contains(p.FetchError.Error(), "unable to find") |
| } |
| |
| func (p *PathFetch) Is404NotFound() bool { |
| return !p.IsOK() && strings.HasSuffix(strings.TrimSpace(p.FetchError.Error()), "Code: 404. Errors:") |
| } |
| |
| type Check interface { |
| Name() string |
| IsEnabled() bool |
| |
| DefaultConfig() map[string]interface{} |
| LoadConfig(config map[string]interface{}) error |
| |
| FetchResources(e *Executor) error |
| |
| Evaluate(e *Executor) ([]*Result, error) |
| } |
| |
| type ResultStatus int |
| |
| const ( |
| ResultNotApplicable ResultStatus = iota |
| ResultOK |
| ResultInformational |
| ResultWarning |
| ResultCritical |
| ResultInvalidVersion |
| ResultInsufficientPermissions |
| ) |
| |
| var ResultStatusNameMap = map[ResultStatus]string{ |
| ResultNotApplicable: "not_applicable", |
| ResultOK: "ok", |
| ResultInformational: "informational", |
| ResultWarning: "warning", |
| ResultCritical: "critical", |
| ResultInvalidVersion: "invalid_version", |
| ResultInsufficientPermissions: "insufficient_permissions", |
| } |
| |
| var NameResultStatusMap = map[string]ResultStatus{ |
| "not_applicable": ResultNotApplicable, |
| "ok": ResultOK, |
| "informational": ResultInformational, |
| "warning": ResultWarning, |
| "critical": ResultCritical, |
| "invalid_version": ResultInvalidVersion, |
| "insufficient_permissions": ResultInsufficientPermissions, |
| } |
| |
| type Result struct { |
| Status ResultStatus `json:"status_code"` |
| StatusDisplay string `json:"status"` |
| Endpoint string `json:"endpoint,omitempty"` |
| Message string `json:"message,omitempty"` |
| } |