open-vault/command/healthcheck/healthcheck.go

286 lines
8.1 KiB
Go

/*
* 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)
}
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"`
}