diff --git a/agent/config/builder.go b/agent/config/builder.go index 19f13c433..f850d5134 100644 --- a/agent/config/builder.go +++ b/agent/config/builder.go @@ -544,6 +544,9 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) { "token": "Token", "root_pki_path": "RootPKIPath", "intermediate_pki_path": "IntermediatePKIPath", + + // Common CA config + "leaf_cert_ttl": "LeafCertTTL", }) } diff --git a/agent/config/runtime_test.go b/agent/config/runtime_test.go index c011765fe..9adc7f55f 100644 --- a/agent/config/runtime_test.go +++ b/agent/config/runtime_test.go @@ -2602,7 +2602,8 @@ func TestFullConfig(t *testing.T) { "connect": { "ca_provider": "consul", "ca_config": { - "RotationPeriod": "90h" + "RotationPeriod": "90h", + "LeafCertTTL": "1h" }, "enabled": true, "proxy_defaults": { @@ -3073,7 +3074,8 @@ func TestFullConfig(t *testing.T) { connect { ca_provider = "consul" ca_config { - "RotationPeriod" = "90h" + rotation_period = "90h" + leaf_cert_ttl = "1h" } enabled = true proxy_defaults { @@ -3687,6 +3689,7 @@ func TestFullConfig(t *testing.T) { ConnectCAProvider: "consul", ConnectCAConfig: map[string]interface{}{ "RotationPeriod": "90h", + "LeafCertTTL": "1h", }, ConnectProxyAllowManagedRoot: false, ConnectProxyAllowManagedAPIRegistration: false, diff --git a/agent/connect/ca/provider_consul.go b/agent/connect/ca/provider_consul.go index 3d2f4ceeb..5cbf744ab 100644 --- a/agent/connect/ca/provider_consul.go +++ b/agent/connect/ca/provider_consul.go @@ -214,8 +214,7 @@ func (c *ConsulProvider) Sign(csr *x509.CertificateRequest) (string, error) { x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth, }, - // todo(kyhavlov): add a way to set the cert lifetime here from the CA config - NotAfter: effectiveNow.Add(3 * 24 * time.Hour), + NotAfter: effectiveNow.Add(c.config.LeafCertTTL), NotBefore: effectiveNow, AuthorityKeyId: keyId, SubjectKeyId: keyId, diff --git a/agent/connect/ca/provider_consul_config.go b/agent/connect/ca/provider_consul_config.go index 9eae88610..9c94f0e62 100644 --- a/agent/connect/ca/provider_consul_config.go +++ b/agent/connect/ca/provider_consul_config.go @@ -10,10 +10,12 @@ import ( ) func ParseConsulCAConfig(raw map[string]interface{}) (*structs.ConsulCAProviderConfig, error) { - var config structs.ConsulCAProviderConfig + config := structs.ConsulCAProviderConfig{ + CommonCAProviderConfig: defaultCommonConfig(), + } + decodeConf := &mapstructure.DecoderConfig{ DecodeHook: ParseDurationFunc(), - ErrorUnused: true, Result: &config, WeaklyTypedInput: true, } @@ -31,6 +33,10 @@ func ParseConsulCAConfig(raw map[string]interface{}) (*structs.ConsulCAProviderC return nil, fmt.Errorf("must provide a private key when providing a root cert") } + if err := config.CommonCAProviderConfig.Validate(); err != nil { + return nil, err + } + return &config, nil } @@ -75,3 +81,9 @@ func Uint8ToString(bs []uint8) string { } return string(b) } + +func defaultCommonConfig() structs.CommonCAProviderConfig { + return structs.CommonCAProviderConfig{ + LeafCertTTL: 3 * 24 * time.Hour, + } +} diff --git a/agent/connect/ca/provider_consul_test.go b/agent/connect/ca/provider_consul_test.go index 7c510fff1..2a37f1b94 100644 --- a/agent/connect/ca/provider_consul_test.go +++ b/agent/connect/ca/provider_consul_test.go @@ -117,12 +117,13 @@ func TestConsulCAProvider_Bootstrap_WithCert(t *testing.T) { func TestConsulCAProvider_SignLeaf(t *testing.T) { t.Parallel() - assert := assert.New(t) + require := require.New(t) conf := testConsulCAConfig() + conf.Config["LeafCertTTL"] = "1h" delegate := newMockDelegate(t, conf) provider, err := NewConsulProvider(conf.Config, delegate) - assert.NoError(err) + require.NoError(err) spiffeService := &connect.SpiffeIDService{ Host: "node1", @@ -136,20 +137,21 @@ func TestConsulCAProvider_SignLeaf(t *testing.T) { raw, _ := connect.TestCSR(t, spiffeService) csr, err := connect.ParseCSR(raw) - assert.NoError(err) + require.NoError(err) cert, err := provider.Sign(csr) - assert.NoError(err) + require.NoError(err) parsed, err := connect.ParseCert(cert) - assert.NoError(err) - assert.Equal(parsed.URIs[0], spiffeService.URI()) - assert.Equal(parsed.Subject.CommonName, "foo") - assert.Equal(uint64(2), parsed.SerialNumber.Uint64()) + require.NoError(err) + require.Equal(parsed.URIs[0], spiffeService.URI()) + require.Equal(parsed.Subject.CommonName, "foo") + require.Equal(uint64(2), parsed.SerialNumber.Uint64()) // Ensure the cert is valid now and expires within the correct limit. - assert.True(parsed.NotAfter.Sub(time.Now()) < 3*24*time.Hour) - assert.True(parsed.NotBefore.Before(time.Now())) + now := time.Now() + require.True(parsed.NotAfter.Sub(now) < time.Hour) + require.True(parsed.NotBefore.Before(now)) } // Generate a new cert for another service and make sure @@ -159,20 +161,20 @@ func TestConsulCAProvider_SignLeaf(t *testing.T) { raw, _ := connect.TestCSR(t, spiffeService) csr, err := connect.ParseCSR(raw) - assert.NoError(err) + require.NoError(err) cert, err := provider.Sign(csr) - assert.NoError(err) + require.NoError(err) parsed, err := connect.ParseCert(cert) - assert.NoError(err) - assert.Equal(parsed.URIs[0], spiffeService.URI()) - assert.Equal(parsed.Subject.CommonName, "bar") - assert.Equal(parsed.SerialNumber.Uint64(), uint64(2)) + require.NoError(err) + require.Equal(parsed.URIs[0], spiffeService.URI()) + require.Equal(parsed.Subject.CommonName, "bar") + require.Equal(parsed.SerialNumber.Uint64(), uint64(2)) // Ensure the cert is valid now and expires within the correct limit. - assert.True(parsed.NotAfter.Sub(time.Now()) < 3*24*time.Hour) - assert.True(parsed.NotBefore.Before(time.Now())) + require.True(parsed.NotAfter.Sub(time.Now()) < 3*24*time.Hour) + require.True(parsed.NotBefore.Before(time.Now())) } } diff --git a/agent/connect/ca/provider_vault.go b/agent/connect/ca/provider_vault.go index 8eaf94565..743ea8957 100644 --- a/agent/connect/ca/provider_vault.go +++ b/agent/connect/ca/provider_vault.go @@ -172,7 +172,7 @@ func (v *VaultProvider) GenerateIntermediate() (string, error) { "allow_any_name": true, "allowed_uri_sans": "spiffe://*", "key_type": "any", - "max_ttl": "72h", + "max_ttl": v.config.LeafCertTTL.String(), "require_cn": false, }) if err != nil { @@ -227,6 +227,7 @@ func (v *VaultProvider) Sign(csr *x509.CertificateRequest) (string, error) { // 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(), + "ttl": v.config.LeafCertTTL.String(), }) if err != nil { return "", fmt.Errorf("error issuing cert: %v", err) @@ -283,10 +284,12 @@ func (v *VaultProvider) Cleanup() error { } func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderConfig, error) { - var config structs.VaultCAProviderConfig + config := structs.VaultCAProviderConfig{ + CommonCAProviderConfig: defaultCommonConfig(), + } decodeConf := &mapstructure.DecoderConfig{ - ErrorUnused: true, + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), Result: &config, WeaklyTypedInput: true, } @@ -318,5 +321,9 @@ func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderCon config.IntermediatePKIPath += "/" } + if err := config.CommonCAProviderConfig.Validate(); err != nil { + return nil, err + } + return &config, nil } diff --git a/agent/connect/ca/provider_vault_test.go b/agent/connect/ca/provider_vault_test.go index 37d686549..5c248e8dc 100644 --- a/agent/connect/ca/provider_vault_test.go +++ b/agent/connect/ca/provider_vault_test.go @@ -16,6 +16,10 @@ import ( ) func testVaultCluster(t *testing.T) (*VaultProvider, *vault.Core, net.Listener) { + return testVaultClusterWithConfig(t, nil) +} + +func testVaultClusterWithConfig(t *testing.T, rawConf map[string]interface{}) (*VaultProvider, *vault.Core, net.Listener) { if err := vault.AddTestLogicalBackend("pki", pki.Factory); err != nil { t.Fatal(err) } @@ -23,12 +27,17 @@ func testVaultCluster(t *testing.T) (*VaultProvider, *vault.Core, net.Listener) ln, addr := vaulthttp.TestServer(t, core) - provider, err := NewVaultProvider(map[string]interface{}{ + conf := map[string]interface{}{ "Address": addr, "Token": token, "RootPKIPath": "pki-root/", "IntermediatePKIPath": "pki-intermediate/", - }, "asdf") + } + for k, v := range rawConf { + conf[k] = v + } + + provider, err := NewVaultProvider(conf, "asdf") if err != nil { t.Fatal(err) } @@ -87,7 +96,9 @@ func TestVaultCAProvider_SignLeaf(t *testing.T) { t.Parallel() require := require.New(t) - provider, core, listener := testVaultCluster(t) + provider, core, listener := testVaultClusterWithConfig(t, map[string]interface{}{ + "LeafCertTTL": "1h", + }) defer core.Shutdown() defer listener.Close() client, err := vaultapi.NewClient(&vaultapi.Config{ @@ -120,8 +131,9 @@ func TestVaultCAProvider_SignLeaf(t *testing.T) { firstSerial = parsed.SerialNumber.Uint64() // Ensure the cert is valid now and expires within the correct limit. - require.True(parsed.NotAfter.Sub(time.Now()) < 3*24*time.Hour) - require.True(parsed.NotBefore.Before(time.Now())) + now := time.Now() + require.True(parsed.NotAfter.Sub(now) < time.Hour) + require.True(parsed.NotBefore.Before(now)) } // Generate a new cert for another service and make sure @@ -142,7 +154,7 @@ func TestVaultCAProvider_SignLeaf(t *testing.T) { require.NotEqual(firstSerial, parsed.SerialNumber.Uint64()) // Ensure the cert is valid now and expires within the correct limit. - require.True(parsed.NotAfter.Sub(time.Now()) < 3*24*time.Hour) + require.True(parsed.NotAfter.Sub(time.Now()) < time.Hour) require.True(parsed.NotBefore.Before(time.Now())) } } diff --git a/agent/connect_ca_endpoint_test.go b/agent/connect_ca_endpoint_test.go index 14bc35805..84fdb95d6 100644 --- a/agent/connect_ca_endpoint_test.go +++ b/agent/connect_ca_endpoint_test.go @@ -67,6 +67,7 @@ func TestConnectCAConfig(t *testing.T) { expected := &structs.ConsulCAProviderConfig{ RotationPeriod: 90 * 24 * time.Hour, } + expected.LeafCertTTL = 72 * time.Hour // Get the initial config. { @@ -88,7 +89,8 @@ func TestConnectCAConfig(t *testing.T) { { "Provider": "consul", "Config": { - "RotationPeriod": 3600000000000 + "LeafCertTTL": "72h", + "RotationPeriod": "1h" } }`)) req, _ := http.NewRequest("PUT", "/v1/connect/ca/configuration", body) diff --git a/agent/consul/config.go b/agent/consul/config.go index b878d9a99..66ca27d6e 100644 --- a/agent/consul/config.go +++ b/agent/consul/config.go @@ -438,6 +438,7 @@ func DefaultConfig() *Config { Provider: "consul", Config: map[string]interface{}{ "RotationPeriod": "2160h", + "LeafCertTTL": "72h", }, }, diff --git a/agent/consul/leader.go b/agent/consul/leader.go index bab926997..e959e365e 100644 --- a/agent/consul/leader.go +++ b/agent/consul/leader.go @@ -32,10 +32,6 @@ var ( // caRootPruneInterval is how often we check for stale CARoots to remove. caRootPruneInterval = time.Hour - // caRootExpireDuration is the duration after which an inactive root is considered - // "expired". Currently this is based on the default leaf cert TTL of 3 days. - caRootExpireDuration = 7 * 24 * time.Hour - // minAutopilotVersion is the minimum Consul version in which Autopilot features // are supported. minAutopilotVersion = version.Must(version.NewVersion("0.8.0")) @@ -601,14 +597,25 @@ func (s *Server) pruneCARoots() error { return nil } - idx, roots, err := s.fsm.State().CARoots(nil) + state := s.fsm.State() + idx, roots, err := state.CARoots(nil) + if err != nil { + return err + } + + _, caConf, err := state.CAConfig() + if err != nil { + return err + } + + common, err := caConf.GetCommonConfig() if err != nil { return err } var newRoots structs.CARoots for _, r := range roots { - if !r.Active && !r.RotatedOutAt.IsZero() && time.Now().Sub(r.RotatedOutAt) > caRootExpireDuration { + if !r.Active && !r.RotatedOutAt.IsZero() && time.Now().Sub(r.RotatedOutAt) > common.LeafCertTTL*2 { s.logger.Printf("[INFO] connect: pruning old unused root CA (ID: %s)", r.ID) continue } diff --git a/agent/consul/leader_test.go b/agent/consul/leader_test.go index 18769ff87..c56477abf 100644 --- a/agent/consul/leader_test.go +++ b/agent/consul/leader_test.go @@ -1008,7 +1008,6 @@ func TestLeader_ACL_Initialization(t *testing.T) { func TestLeader_CARootPruning(t *testing.T) { t.Parallel() - caRootExpireDuration = 500 * time.Millisecond caRootPruneInterval = 200 * time.Millisecond require := require.New(t) @@ -1036,9 +1035,11 @@ func TestLeader_CARootPruning(t *testing.T) { newConfig := &structs.CAConfiguration{ Provider: "consul", Config: map[string]interface{}{ + "LeafCertTTL": 500 * time.Millisecond, "PrivateKey": newKey, "RootCert": "", "RotationPeriod": 90 * 24 * time.Hour, + "SkipValidate": true, }, } { @@ -1056,7 +1057,7 @@ func TestLeader_CARootPruning(t *testing.T) { require.NoError(err) require.Len(roots, 2) - time.Sleep(caRootExpireDuration * 2) + time.Sleep(2 * time.Second) // Now the old root should be pruned. _, roots, err = s1.fsm.State().CARoots(nil) diff --git a/agent/structs/connect_ca.go b/agent/structs/connect_ca.go index 375a7df32..1e869fd45 100644 --- a/agent/structs/connect_ca.go +++ b/agent/structs/connect_ca.go @@ -1,7 +1,10 @@ package structs import ( + "fmt" "time" + + "github.com/mitchellh/mapstructure" ) // IndexedCARoots is the list of currently trusted CA Roots. @@ -192,7 +195,54 @@ type CAConfiguration struct { RaftIndex } +func (c *CAConfiguration) GetCommonConfig() (*CommonCAProviderConfig, error) { + if c == nil { + return nil, fmt.Errorf("config map was nil") + } + + var config CommonCAProviderConfig + decodeConf := &mapstructure.DecoderConfig{ + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), + Result: &config, + } + + decoder, err := mapstructure.NewDecoder(decodeConf) + if err != nil { + return nil, err + } + + if err := decoder.Decode(c.Config); err != nil { + return nil, fmt.Errorf("error decoding config: %s", err) + } + + return &config, nil +} + +type CommonCAProviderConfig struct { + LeafCertTTL time.Duration + + SkipValidate bool +} + +func (c CommonCAProviderConfig) Validate() error { + if c.SkipValidate { + return nil + } + + if c.LeafCertTTL < time.Hour { + return fmt.Errorf("leaf cert TTL must be greater than 1h") + } + + if c.LeafCertTTL > 365*24*time.Hour { + return fmt.Errorf("leaf cert TTL must be less than 1 year") + } + + return nil +} + type ConsulCAProviderConfig struct { + CommonCAProviderConfig `mapstructure:",squash"` + PrivateKey string RootCert string RotationPeriod time.Duration @@ -208,6 +258,8 @@ type CAConsulProviderState struct { } type VaultCAProviderConfig struct { + CommonCAProviderConfig `mapstructure:",squash"` + Address string Token string RootPKIPath string diff --git a/api/connect_ca.go b/api/connect_ca.go index 947f70976..a863d21d4 100644 --- a/api/connect_ca.go +++ b/api/connect_ca.go @@ -21,8 +21,15 @@ type CAConfig struct { ModifyIndex uint64 } +// CommonCAProviderConfig is the common options available to all CA providers. +type CommonCAProviderConfig struct { + LeafCertTTL time.Duration +} + // ConsulCAProviderConfig is the config for the built-in Consul CA provider. type ConsulCAProviderConfig struct { + CommonCAProviderConfig `mapstructure:",squash"` + PrivateKey string RootCert string RotationPeriod time.Duration diff --git a/api/connect_ca_test.go b/api/connect_ca_test.go index f5dd6b469..912920976 100644 --- a/api/connect_ca_test.go +++ b/api/connect_ca_test.go @@ -63,6 +63,7 @@ func TestAPI_ConnectCAConfig_get_set(t *testing.T) { expected := &ConsulCAProviderConfig{ RotationPeriod: 90 * 24 * time.Hour, } + expected.LeafCertTTL = 72 * time.Hour // This fails occasionally if server doesn't have time to bootstrap CA so // retry diff --git a/website/source/api/connect/ca.html.md b/website/source/api/connect/ca.html.md index d8bf80fed..e60fd1f2b 100644 --- a/website/source/api/connect/ca.html.md +++ b/website/source/api/connect/ca.html.md @@ -91,8 +91,7 @@ $ curl \ { "Provider": "consul", "Config": { - "PrivateKey": null, - "RootCert": null, + "LeafCertTTL": "72h", "RotationPeriod": "2160h" }, "CreateIndex": 5, @@ -133,8 +132,10 @@ providers, see [Provider Config](/docs/connect/ca.html). { "Provider": "consul", "Config": { + "LeafCertTTL": "72h", "PrivateKey": "-----BEGIN RSA PRIVATE KEY-----...", "RootCert": "-----BEGIN CERTIFICATE-----...", + "RotationPeriod": "2160h" } } ``` diff --git a/website/source/docs/agent/options.html.md b/website/source/docs/agent/options.html.md index 67f707def..ed66e8e3a 100644 --- a/website/source/docs/agent/options.html.md +++ b/website/source/docs/agent/options.html.md @@ -728,6 +728,21 @@ Consul will not enable TLS for the HTTP API unless the `https` port has been ass `write` access to this backend, as well as permission to mount the backend at this path if it is not already mounted. + #### Common CA Config Options + +

There are also a number of common configuration options supported by all providers:

+ + * `leaf_cert_ttl` The upper bound on the + lease duration of a leaf certificate issued for a service. In most cases a new leaf certificate will be + requested by a proxy before this limit is reached. This is also the effective limit on how long a server + outage can last (with no leader) before network connections will start being rejected, and as a result the + defaults is `72h` to last through a weekend without intervention. This value cannot be lower than 1 hour + or higher than 1 year. + + This value is also used when rotating out old root certificates from the cluster. When a root certificate + has been inactive (rotated out) for more than twice the *current* `leaf_cert_ttl`, it will be removed from + the trusted list. + * `proxy` This object allows setting options for the Connect proxies. The following sub-keys are available: * `allow_managed_api_registration` Allows managed proxies to be configured with services that are registered via the Agent HTTP API. Enabling this would allow anyone with permission to register a service to define a command to execute for the proxy. By default, this is false to protect against arbitrary process execution. diff --git a/website/source/docs/connect/ca.html.md b/website/source/docs/connect/ca.html.md index 74c412434..c3d411f61 100644 --- a/website/source/docs/connect/ca.html.md +++ b/website/source/docs/connect/ca.html.md @@ -88,6 +88,7 @@ $ curl http://localhost:8500/v1/connect/ca/configuration { "Provider": "consul", "Config": { + "LeafCertTTL": "72h", "RotationPeriod": "2160h" }, "CreateIndex": 5, diff --git a/website/source/docs/connect/ca/consul.html.md b/website/source/docs/connect/ca/consul.html.md index e95677681..a3280db9b 100644 --- a/website/source/docs/connect/ca/consul.html.md +++ b/website/source/docs/connect/ca/consul.html.md @@ -53,6 +53,9 @@ is used if configuring in an agent configuration file. bootstrap with the ".consul" TLD. The cluster identifier can be found using the [CA List Roots endpoint](/api/connect/ca.html#list-ca-root-certificates). +There are also [common CA configuration options](/docs/agent/options.html#common-ca-config-options) +that are supported by all CA providers. + ## Specifying a Custom Private Key and Root Certificate By default, a root certificate and private key will be automatically @@ -69,6 +72,7 @@ $ curl localhost:8500/v1/connect/ca/configuration { "Provider": "consul", "Config": { + "LeafCertTTL": "72h", "RotationPeriod": "2160h" }, "CreateIndex": 5, @@ -99,6 +103,7 @@ $ jq -n --arg key "$(cat root.key)" --arg cert "$(cat root.crt)" ' { "Provider": "consul", "Config": { + "LeafCertTTL": "72h", "PrivateKey": $key, "RootCert": $cert, "RotationPeriod": "2160h" @@ -113,6 +118,7 @@ $ cat ca_config.json { "Provider": "consul", "Config": { + "LeafCertTTL": "72h", "PrivateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEArqiy1c3pbT3cSkjdEM1APALUareU...", "RootCert": "-----BEGIN CERTIFICATE-----\nMIIDijCCAnKgAwIBAgIJAOFZ66em1qC7MA0GCSqGSIb3...", "RotationPeriod": "2160h"