connect/ca: add the Vault CA provider
This commit is contained in:
parent
6ecc0c8099
commit
a98b85b25c
|
@ -14,6 +14,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/consul/agent/connect/ca"
|
||||
"github.com/hashicorp/consul/agent/consul"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/ipaddr"
|
||||
|
@ -533,9 +534,16 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) {
|
|||
connectCAConfig := c.Connect.CAConfig
|
||||
if connectCAConfig != nil {
|
||||
TranslateKeys(connectCAConfig, map[string]string{
|
||||
// Consul CA config
|
||||
"private_key": "PrivateKey",
|
||||
"root_cert": "RootCert",
|
||||
"rotation_period": "RotationPeriod",
|
||||
|
||||
// Vault CA config
|
||||
"address": "Address",
|
||||
"token": "Token",
|
||||
"root_pki_path": "RootPKIPath",
|
||||
"intermediate_pki_path": "IntermediatePKIPath",
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -925,6 +933,26 @@ func (b *Builder) Validate(rt RuntimeConfig) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate the given Connect CA provider config
|
||||
validCAProviders := map[string]bool{
|
||||
structs.ConsulCAProvider: true,
|
||||
structs.VaultCAProvider: true,
|
||||
}
|
||||
if _, ok := validCAProviders[rt.ConnectCAProvider]; !ok {
|
||||
return fmt.Errorf("%s is not a valid CA provider", rt.ConnectCAProvider)
|
||||
} else {
|
||||
switch rt.ConnectCAProvider {
|
||||
case structs.ConsulCAProvider:
|
||||
if _, err := ca.ParseConsulCAConfig(rt.ConnectCAConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
case structs.VaultCAProvider:
|
||||
if _, err := ca.ParseVaultCAConfig(rt.ConnectCAConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// warnings
|
||||
//
|
||||
|
|
|
@ -128,16 +128,9 @@ func (c *ConsulProvider) ActiveIntermediate() (string, error) {
|
|||
}
|
||||
|
||||
// We aren't maintaining separate root/intermediate CAs for the builtin
|
||||
// provider, so just generate a CSR for the active root.
|
||||
// provider, so just return the root.
|
||||
func (c *ConsulProvider) GenerateIntermediate() (string, error) {
|
||||
ca, err := c.ActiveIntermediate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// todo(kyhavlov): make a new intermediate here
|
||||
|
||||
return ca, err
|
||||
return c.ActiveIntermediate()
|
||||
}
|
||||
|
||||
// Remove the state store entry for this provider instance.
|
||||
|
|
|
@ -67,7 +67,7 @@ func testConsulCAConfig() *structs.CAConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCAProvider_Bootstrap(t *testing.T) {
|
||||
func TestConsulCAProvider_Bootstrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert := assert.New(t)
|
||||
|
@ -91,7 +91,7 @@ func TestCAProvider_Bootstrap(t *testing.T) {
|
|||
assert.Equal(parsed.URIs[0].String(), fmt.Sprintf("spiffe://%s.consul", conf.ClusterID))
|
||||
}
|
||||
|
||||
func TestCAProvider_Bootstrap_WithCert(t *testing.T) {
|
||||
func TestConsulCAProvider_Bootstrap_WithCert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Make sure setting a custom private key/root cert works.
|
||||
|
@ -112,7 +112,7 @@ func TestCAProvider_Bootstrap_WithCert(t *testing.T) {
|
|||
assert.Equal(root, rootCA.RootCert)
|
||||
}
|
||||
|
||||
func TestCAProvider_SignLeaf(t *testing.T) {
|
||||
func TestConsulCAProvider_SignLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert := assert.New(t)
|
||||
|
@ -174,7 +174,7 @@ func TestCAProvider_SignLeaf(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCAProvider_CrossSignCA(t *testing.T) {
|
||||
func TestConsulCAProvider_CrossSignCA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert := assert.New(t)
|
||||
|
|
|
@ -0,0 +1,284 @@
|
|||
package ca
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
vaultapi "github.com/hashicorp/vault/api"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
const VaultCALeafCertRole = "leaf-cert"
|
||||
|
||||
var ErrBackendNotMounted = fmt.Errorf("backend not mounted")
|
||||
var ErrBackendNotInitialized = fmt.Errorf("backend not initialized")
|
||||
|
||||
type VaultProvider struct {
|
||||
config *structs.VaultCAProviderConfig
|
||||
client *vaultapi.Client
|
||||
clusterId string
|
||||
}
|
||||
|
||||
// NewVaultProvider returns a vault provider with its root and intermediate PKI
|
||||
// backends mounted and initialized. If the root backend is not set up already,
|
||||
// it will be mounted/generated as needed, but any existing state will not be
|
||||
// overwritten.
|
||||
func NewVaultProvider(rawConfig map[string]interface{}, clusterId string, client *vaultapi.Client) (*VaultProvider, error) {
|
||||
conf, err := ParseVaultCAConfig(rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// todo(kyhavlov): figure out the right way to pass the TLS config
|
||||
if client == nil {
|
||||
clientConf := &vaultapi.Config{
|
||||
Address: conf.Address,
|
||||
}
|
||||
client, err = vaultapi.NewClient(clientConf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.SetToken(conf.Token)
|
||||
}
|
||||
|
||||
provider := &VaultProvider{
|
||||
config: conf,
|
||||
client: client,
|
||||
clusterId: clusterId,
|
||||
}
|
||||
|
||||
// Set up the root PKI backend if necessary.
|
||||
_, err = provider.ActiveRoot()
|
||||
switch err {
|
||||
case ErrBackendNotMounted:
|
||||
err := client.Sys().Mount(conf.RootPKIPath, &vaultapi.MountInput{
|
||||
Type: "pki",
|
||||
Description: "root CA backend for Consul Connect",
|
||||
Config: vaultapi.MountConfigInput{
|
||||
MaxLeaseTTL: "8760h",
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fallthrough
|
||||
case ErrBackendNotInitialized:
|
||||
_, err := client.Logical().Write(conf.RootPKIPath+"root/generate/internal", map[string]interface{}{
|
||||
"alt_names": fmt.Sprintf("URI:spiffe://%s.consul", clusterId),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the intermediate backend.
|
||||
if _, err := provider.GenerateIntermediate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (v *VaultProvider) ActiveRoot() (string, error) {
|
||||
return v.getCA(v.config.RootPKIPath)
|
||||
}
|
||||
|
||||
func (v *VaultProvider) ActiveIntermediate() (string, error) {
|
||||
return v.getCA(v.config.IntermediatePKIPath)
|
||||
}
|
||||
|
||||
// getCA returns the raw CA cert for the given endpoint if there is one.
|
||||
// We have to use the raw NewRequest call here instead of Logical().Read
|
||||
// because the endpoint only returns the raw PEM contents of the CA cert
|
||||
// and not the typical format of the secrets endpoints.
|
||||
func (v *VaultProvider) getCA(path string) (string, error) {
|
||||
req := v.client.NewRequest("GET", "/v1/"+path+"/ca/pem")
|
||||
resp, err := v.client.RawRequest(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
return "", ErrBackendNotMounted
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
bytes, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
root := string(bytes)
|
||||
if root == "" {
|
||||
return "", ErrBackendNotInitialized
|
||||
}
|
||||
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// GenerateIntermediate mounts the configured intermediate PKI backend if
|
||||
// necessary, then generates and signs a new CA CSR using the root PKI backend
|
||||
// and updates the intermediate backend to use that new certificate.
|
||||
func (v *VaultProvider) GenerateIntermediate() (string, error) {
|
||||
mounts, err := v.client.Sys().ListMounts()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Mount the backend if it isn't mounted already.
|
||||
if _, ok := mounts[v.config.IntermediatePKIPath]; !ok {
|
||||
err := v.client.Sys().Mount(v.config.IntermediatePKIPath, &vaultapi.MountInput{
|
||||
Type: "pki",
|
||||
Description: "intermediate CA backend for Consul Connect",
|
||||
Config: vaultapi.MountConfigInput{
|
||||
MaxLeaseTTL: "2160h",
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the role for issuing leaf certs if it doesn't exist yet
|
||||
rolePath := v.config.IntermediatePKIPath + "roles/" + VaultCALeafCertRole
|
||||
role, err := v.client.Logical().Read(rolePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if role == nil {
|
||||
_, err := v.client.Logical().Write(rolePath, map[string]interface{}{
|
||||
"allowed_domains": fmt.Sprintf("%s.consul", v.clusterId),
|
||||
"max_ttl": "72h",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new intermediate CSR for the root to sign.
|
||||
csr, err := v.client.Logical().Write(v.config.IntermediatePKIPath+"intermediate/generate/internal", map[string]interface{}{
|
||||
"common_name": "Vault CA Intermediate Authority",
|
||||
"alt_names": fmt.Sprintf("URI:spiffe://%s.consul", v.clusterId),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if csr == nil || csr.Data["csr"] == nil {
|
||||
return "", fmt.Errorf("got empty value when generating intermediate CSR")
|
||||
}
|
||||
|
||||
// Sign the CSR with the root backend.
|
||||
intermediate, err := v.client.Logical().Write(v.config.RootPKIPath+"root/sign-intermediate", map[string]interface{}{
|
||||
"csr": csr.Data["csr"],
|
||||
"format": "pem_bundle",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if intermediate == nil || intermediate.Data["certificate"] == nil {
|
||||
return "", fmt.Errorf("got empty value when generating intermediate certificate")
|
||||
}
|
||||
|
||||
// Set the intermediate backend to use the new certificate.
|
||||
_, err = v.client.Logical().Write(v.config.IntermediatePKIPath+"intermediate/set-signed", map[string]interface{}{
|
||||
"certificate": intermediate.Data["certificate"],
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.ActiveIntermediate()
|
||||
}
|
||||
|
||||
// Sign calls the configured role in the intermediate PKI backend to issue
|
||||
// a new leaf certificate based on the provided CSR, with the issuing
|
||||
// intermediate CA cert attached.
|
||||
func (v *VaultProvider) Sign(csr *x509.CertificateRequest) (string, error) {
|
||||
var pemBuf bytes.Buffer
|
||||
if err := pem.Encode(&pemBuf, &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr.Raw}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use the leaf cert role to sign a new cert for this CSR.
|
||||
response, err := v.client.Logical().Write(v.config.IntermediatePKIPath+"sign/"+VaultCALeafCertRole, map[string]interface{}{
|
||||
"csr": pemBuf.String(),
|
||||
"common_name": csr.Subject.CommonName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
if response == nil || response.Data["certificate"] == "" || response.Data["issuing_ca"] == "" {
|
||||
return "", fmt.Errorf("certificate info returned from Vault was blank")
|
||||
}
|
||||
|
||||
cert, ok := response.Data["certificate"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("certificate was not a string")
|
||||
}
|
||||
ca, ok := response.Data["issuing_ca"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("issuing_ca was not a string")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s\n%s", cert, ca), nil
|
||||
}
|
||||
|
||||
// todo(kyhavlov): decide which vault endpoint to use here
|
||||
func (v *VaultProvider) CrossSignCA(cert *x509.Certificate) (string, error) {
|
||||
var pemBuf bytes.Buffer
|
||||
if err := pem.Encode(&pemBuf, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pemBuf.String(), nil
|
||||
}
|
||||
|
||||
// Cleanup unmounts the configured intermediate PKI backend. It's fine to tear
|
||||
// this down and recreate it on small config changes because the intermediate
|
||||
// certs get bundled with the leaf certs, so there's no cost to the CA changing.
|
||||
func (v *VaultProvider) Cleanup() error {
|
||||
return v.client.Sys().Unmount(v.config.IntermediatePKIPath)
|
||||
}
|
||||
|
||||
func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderConfig, error) {
|
||||
var config structs.VaultCAProviderConfig
|
||||
|
||||
if err := mapstructure.Decode(raw, &config); err != nil {
|
||||
return nil, fmt.Errorf("error decoding config: %s", err)
|
||||
}
|
||||
|
||||
if config.Token == "" {
|
||||
return nil, fmt.Errorf("must provide a Vault token")
|
||||
}
|
||||
|
||||
if config.RootPKIPath == "" {
|
||||
return nil, fmt.Errorf("must provide a valid path to a root PKI backend")
|
||||
}
|
||||
if !strings.HasSuffix(config.RootPKIPath, "/") {
|
||||
config.RootPKIPath += "/"
|
||||
}
|
||||
|
||||
if config.IntermediatePKIPath == "" {
|
||||
return nil, fmt.Errorf("must provide a valid path for the intermediate PKI backend")
|
||||
}
|
||||
if !strings.HasSuffix(config.IntermediatePKIPath, "/") {
|
||||
config.IntermediatePKIPath += "/"
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
package ca
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/consul/agent/connect"
|
||||
"github.com/hashicorp/vault/builtin/logical/pki"
|
||||
vaulthttp "github.com/hashicorp/vault/http"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/vault"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testVaultCluster(t *testing.T) (*VaultProvider, *vault.TestCluster) {
|
||||
coreConfig := &vault.CoreConfig{
|
||||
LogicalBackends: map[string]logical.Factory{
|
||||
"pki": pki.Factory,
|
||||
},
|
||||
}
|
||||
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
|
||||
HandlerFunc: vaulthttp.Handler,
|
||||
NumCores: 1,
|
||||
})
|
||||
cluster.Start()
|
||||
|
||||
client := cluster.Cores[0].Client
|
||||
|
||||
provider, err := NewVaultProvider(map[string]interface{}{
|
||||
"Address": client.Address(),
|
||||
"Token": cluster.RootToken,
|
||||
"RootPKIPath": "pki-root/",
|
||||
"IntermediatePKIPath": "pki-intermediate/",
|
||||
}, "asdf", client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return provider, cluster
|
||||
}
|
||||
|
||||
func TestVaultCAProvider_Bootstrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require := require.New(t)
|
||||
provider, vaultCluster := testVaultCluster(t)
|
||||
defer vaultCluster.Cleanup()
|
||||
client := vaultCluster.Cores[0].Client
|
||||
|
||||
cases := []struct {
|
||||
certFunc func() (string, error)
|
||||
backendPath string
|
||||
}{
|
||||
{
|
||||
certFunc: provider.ActiveRoot,
|
||||
backendPath: "pki-root/",
|
||||
},
|
||||
{
|
||||
certFunc: provider.ActiveIntermediate,
|
||||
backendPath: "pki-intermediate/",
|
||||
},
|
||||
}
|
||||
|
||||
// Verify the root and intermediate certs match the ones in the vault backends
|
||||
for _, tc := range cases {
|
||||
cert, err := tc.certFunc()
|
||||
require.NoError(err)
|
||||
req := client.NewRequest("GET", "v1/"+tc.backendPath+"ca/pem")
|
||||
resp, err := client.RawRequest(req)
|
||||
require.NoError(err)
|
||||
bytes, err := ioutil.ReadAll(resp.Body)
|
||||
require.NoError(err)
|
||||
require.Equal(cert, string(bytes))
|
||||
|
||||
// Should be a valid cert
|
||||
parsed, err := connect.ParseCert(cert)
|
||||
require.NoError(err)
|
||||
require.Equal(parsed.URIs[0].String(), fmt.Sprintf("spiffe://%s.consul", provider.clusterId))
|
||||
}
|
||||
}
|
|
@ -79,14 +79,15 @@ func (s *HTTPServer) ConnectCAConfigurationSet(resp http.ResponseWriter, req *ht
|
|||
// string values that get converted to []uint8 end up getting output back
|
||||
// to the user in base64-encoded form.
|
||||
func fixupConfig(conf *structs.CAConfiguration) {
|
||||
if conf.Provider == structs.ConsulCAProvider {
|
||||
for k, v := range conf.Config {
|
||||
if raw, ok := v.([]uint8); ok {
|
||||
conf.Config[k] = ca.Uint8ToString(raw)
|
||||
}
|
||||
}
|
||||
// todo(kyhavlov): should we be hiding this and the vault token?
|
||||
if conf.Provider == structs.ConsulCAProvider {
|
||||
if v, ok := conf.Config["PrivateKey"]; ok && v != "" {
|
||||
conf.Config["PrivateKey"] = "hidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -511,6 +511,8 @@ func (s *Server) createCAProvider(conf *structs.CAConfiguration) (ca.Provider, e
|
|||
switch conf.Provider {
|
||||
case structs.ConsulCAProvider:
|
||||
return ca.NewConsulProvider(conf.Config, &consulCADelegate{s})
|
||||
case structs.VaultCAProvider:
|
||||
return ca.NewVaultProvider(conf.Config, conf.ClusterID, nil)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown CA provider %q", conf.Provider)
|
||||
}
|
||||
|
|
|
@ -168,6 +168,7 @@ func (q *CARequest) RequestDatacenter() string {
|
|||
|
||||
const (
|
||||
ConsulCAProvider = "consul"
|
||||
VaultCAProvider = "vault"
|
||||
)
|
||||
|
||||
// CAConfiguration is the configuration for the current CA plugin.
|
||||
|
@ -200,3 +201,10 @@ type CAConsulProviderState struct {
|
|||
|
||||
RaftIndex
|
||||
}
|
||||
|
||||
type VaultCAProviderConfig struct {
|
||||
Address string
|
||||
Token string
|
||||
RootPKIPath string
|
||||
IntermediatePKIPath string
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue