blob: 8ffcc6df71def015ef420d65e750fa063297aea0 [file] [log] [blame]
// Copyright Istio Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chiron
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"sync"
"time"
)
import (
"istio.io/pkg/log"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
)
import (
"github.com/apache/dubbo-go-pixiu/security/pkg/pki/ca"
"github.com/apache/dubbo-go-pixiu/security/pkg/pki/util"
certutil "github.com/apache/dubbo-go-pixiu/security/pkg/util"
)
const (
// IstioDNSSecretType is the Istio DNS secret annotation type
IstioDNSSecretType = "istio.io/dns-key-and-cert"
// For debugging, set the resync period to be a shorter period.
secretResyncPeriod = 10 * time.Second
recommendedMinGracePeriodRatio = 0.2
recommendedMaxGracePeriodRatio = 0.8
// The size of a private key for a leaf certificate.
keySize = 2048
// The number of retries when requesting to create secret.
secretCreationRetry = 3
// The number of tries for reading a certificate
maxNumCertRead = 10
// The interval for reading a certificate
certReadInterval = 500 * time.Millisecond
)
var certWatchTimeout = 5 * time.Second
// WebhookController manages the service accounts' secrets that contains Istio keys and certificates.
type WebhookController struct {
// The secret names of the services for which Chiron manage certs
secretNames []string
// The DNS names of the services for which Chiron manage certs
dnsNames []string
// The namespaces of the Secrets for which Chiron manage certs
secretNamespace string
// Current CA certificate
CACert []byte
clientset clientset.Interface
// certificate issuer
certIssuer string
// Controller and store for secret objects.
scrtController cache.Controller
scrtStore cache.Store
// The file path to the k8s CA certificate
k8sCaCertFile string
minGracePeriod time.Duration
certMutex sync.RWMutex
// Ratio of the grace period for the certificate rotation.
gracePeriodRatio float32
certUtil certutil.CertUtil
}
// NewWebhookController returns a pointer to a newly constructed WebhookController instance.
func NewWebhookController(gracePeriodRatio float32, minGracePeriod time.Duration,
client clientset.Interface,
k8sCaCertFile string,
secretNames, dnsNames []string,
secretNamespace string, certIssuer string) (*WebhookController, error) {
if gracePeriodRatio < 0 || gracePeriodRatio > 1 {
return nil, fmt.Errorf("grace period ratio %f should be within [0, 1]", gracePeriodRatio)
}
if gracePeriodRatio < recommendedMinGracePeriodRatio || gracePeriodRatio > recommendedMaxGracePeriodRatio {
log.Warnf("grace period ratio %f is out of the recommended window [%.2f, %.2f]",
gracePeriodRatio, recommendedMinGracePeriodRatio, recommendedMaxGracePeriodRatio)
}
if len(secretNames) != len(dnsNames) {
return nil, fmt.Errorf("the size of secret names must be the same as the size of dns names")
}
// Check secret names are unique
set := make(map[string]bool) // New empty set
for _, n := range secretNames {
set[n] = true // Add
}
if len(set) != len(secretNames) {
return nil, fmt.Errorf("the secret names must be unique")
}
c := &WebhookController{
gracePeriodRatio: gracePeriodRatio,
minGracePeriod: minGracePeriod,
k8sCaCertFile: k8sCaCertFile,
clientset: client,
secretNames: secretNames,
dnsNames: dnsNames,
secretNamespace: secretNamespace,
certUtil: certutil.NewCertUtil(int(gracePeriodRatio * 100)),
certIssuer: certIssuer,
}
// read CA cert at the beginning of launching the controller.
_, err := reloadCACert(c)
if err != nil {
return nil, err
}
if len(secretNames) == 0 {
log.Warn("the input secrets are empty, no services to manage certificates for")
} else {
istioSecretSelector := fields.SelectorFromSet(map[string]string{"type": IstioDNSSecretType})
scrtLW := cache.NewListWatchFromClient(client.CoreV1().RESTClient(), "secrets", secretNamespace, istioSecretSelector)
// The certificate rotation is handled by scrtUpdated().
c.scrtStore, c.scrtController = cache.NewInformer(scrtLW, &v1.Secret{}, secretResyncPeriod, cache.ResourceEventHandlerFuncs{
DeleteFunc: c.scrtDeleted,
UpdateFunc: c.scrtUpdated,
})
}
return c, nil
}
// Run starts the WebhookController until stopCh is notified.
func (wc *WebhookController) Run(stopCh <-chan struct{}) {
// Create secrets containing certificates
for i, secretName := range wc.secretNames {
err := wc.upsertSecret(secretName, wc.dnsNames[i], wc.secretNamespace)
if err != nil {
log.Errorf("error when upserting secret (%v) in ns (%v): %v", secretName, wc.secretNamespace, err)
}
}
if len(wc.secretNames) > 0 {
// Manage the secrets
go wc.scrtController.Run(stopCh)
// upsertSecret to update and insert secret
// it throws error if the secret cache is not synchronized, but the secret exists in the system.
// Hence waiting for the cache is synced.
if !cache.WaitForCacheSync(stopCh, wc.scrtController.HasSynced) {
log.Error("failed to wait for cache sync")
}
}
}
func (wc *WebhookController) upsertSecret(secretName, dnsName, secretNamespace string) error {
secret := &v1.Secret{
Data: map[string][]byte{},
ObjectMeta: metav1.ObjectMeta{
Annotations: nil,
Name: secretName,
Namespace: secretNamespace,
},
Type: IstioDNSSecretType,
}
existingSecret, err := wc.clientset.CoreV1().Secrets(secretNamespace).Get(context.TODO(), secretName, metav1.GetOptions{})
if err == nil && existingSecret != nil {
log.Debugf("upsertSecret(): the secret (%v) in namespace (%v) exists, return",
secretName, secretNamespace)
// Do nothing for existing secrets. Rotating expiring certs are handled by the `scrtUpdated` method.
return nil
}
requestedLifetime := time.Duration(0)
// Now we know the secret does not exist yet. So we create a new one.
chain, key, caCert, err := GenKeyCertK8sCA(wc.clientset, dnsName, secretName, secretNamespace, wc.k8sCaCertFile, wc.certIssuer, true, requestedLifetime)
if err != nil {
log.Errorf("failed to generate key and certificate for secret %v in namespace %v (error %v)",
secretName, secretNamespace, err)
return err
}
secret.Data = map[string][]byte{
ca.CertChainFile: chain,
ca.PrivateKeyFile: key,
ca.RootCertFile: caCert,
}
// We retry several times when create secret to mitigate transient network failures.
for i := 0; i < secretCreationRetry; i++ {
_, err = wc.clientset.CoreV1().Secrets(secretNamespace).Create(context.TODO(), secret, metav1.CreateOptions{})
if err == nil || errors.IsAlreadyExists(err) {
if errors.IsAlreadyExists(err) {
log.Infof("Istio secret \"%s\" in namespace \"%s\" already exists", secretName, secretNamespace)
}
break
}
log.Warnf("failed to create secret in attempt %v/%v, (error: %s)", i+1, secretCreationRetry, err)
time.Sleep(time.Second)
}
if err != nil && !errors.IsAlreadyExists(err) {
log.Errorf("failed to create secret \"%s\" in namespace \"%s\" (error: %s), retries %v times",
secretName, secretNamespace, err, secretCreationRetry)
return err
}
log.Infof("Istio secret \"%s\" in namespace \"%s\" has been created", secretName, secretNamespace)
return nil
}
func (wc *WebhookController) scrtDeleted(obj interface{}) {
log.Debugf("enter WebhookController.scrtDeleted()")
scrt, ok := obj.(*v1.Secret)
if !ok {
log.Warnf("failed to convert to secret object: %v", obj)
return
}
scrtName := scrt.Name
if wc.isWebhookSecret(scrtName, scrt.GetNamespace()) {
log.Infof("re-create deleted Istio secret %s in namespace %s", scrtName, scrt.GetNamespace())
dnsName, found := wc.getDNSName(scrtName)
if !found {
log.Errorf("failed to find the DNS name of the secret: %v", scrtName)
return
}
err := wc.upsertSecret(scrtName, dnsName, scrt.GetNamespace())
if err != nil {
log.Errorf("re-create deleted Istio secret %s in namespace %s failed: %v",
scrtName, scrt.GetNamespace(), err)
}
}
}
// scrtUpdated() is the callback function for update event. It handles
// the certificate rotations.
func (wc *WebhookController) scrtUpdated(oldObj, newObj interface{}) {
log.Debugf("enter WebhookController.scrtUpdated()")
scrt, ok := newObj.(*v1.Secret)
if !ok {
log.Warnf("failed to convert to secret object: %v", newObj)
return
}
namespace := scrt.GetNamespace()
name := scrt.GetName()
// Only handle webhook secret update events
if !wc.isWebhookSecret(name, namespace) {
return
}
certBytes := scrt.Data[ca.CertChainFile]
_, err := util.ParsePemEncodedCertificate(certBytes)
if err != nil {
log.Warnf("failed to parse certificates in secret %s/%s (error: %v), refreshing the secret.",
namespace, name, err)
if err = wc.refreshSecret(scrt); err != nil {
log.Error(err)
}
return
}
_, waitErr := wc.certUtil.GetWaitTime(certBytes, time.Now(), wc.minGracePeriod)
// Refresh the secret if 1) the certificate contained in the secret is about
// to expire, or 2) the root certificate in the secret is different than the
// one held by the CA (this may happen when the CA is restarted and
// a new self-signed CA cert is generated).
// The secret will be periodically inspected, so an update to the CA certificate
// will eventually lead to the update of workload certificates.
caCert, err := wc.getCACert()
if err != nil {
log.Errorf("failed to get CA certificate: %v", err)
return
}
if waitErr != nil || !bytes.Equal(caCert, scrt.Data[ca.RootCertFile]) {
log.Infof("refreshing secret %s/%s, either the leaf certificate is about to expire "+
"or the root certificate is outdated", namespace, name)
if err = wc.refreshSecret(scrt); err != nil {
log.Errorf("failed to update secret %s/%s (error: %s)", namespace, name, err)
}
}
}
// refreshSecret is an inner func to refresh cert secrets when necessary
func (wc *WebhookController) refreshSecret(scrt *v1.Secret) error {
namespace := scrt.GetNamespace()
scrtName := scrt.Name
dnsName, found := wc.getDNSName(scrtName)
if !found {
return fmt.Errorf("failed to find the service name for the secret (%v) to refresh", scrtName)
}
requestedLifetime := time.Duration(0)
chain, key, caCert, err := GenKeyCertK8sCA(wc.clientset, dnsName, scrtName, namespace, wc.k8sCaCertFile, wc.certIssuer, true, requestedLifetime)
if err != nil {
return err
}
scrt.Data[ca.CertChainFile] = chain
scrt.Data[ca.PrivateKeyFile] = key
scrt.Data[ca.RootCertFile] = caCert
_, err = wc.clientset.CoreV1().Secrets(namespace).Update(context.TODO(), scrt, metav1.UpdateOptions{})
return err
}
// Return whether the input secret name is a Webhook secret
func (wc *WebhookController) isWebhookSecret(name, namespace string) bool {
for _, n := range wc.secretNames {
if name == n && namespace == wc.secretNamespace {
return true
}
}
return false
}
// Get the CA cert. K8sCaCertWatcher handles the update of CA cert.
func (wc *WebhookController) getCACert() ([]byte, error) {
wc.certMutex.Lock()
cp := append([]byte(nil), wc.CACert...)
wc.certMutex.Unlock()
block, _ := pem.Decode(cp)
if block == nil {
return nil, fmt.Errorf("invalid PEM encoded CA certificate")
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return nil, fmt.Errorf("invalid ca certificate (%v), parsing error: %v", string(cp), err)
}
return cp, nil
}
// Get the DNS name for the secret. Return the DNS name and whether it is found.
func (wc *WebhookController) getDNSName(secretName string) (string, bool) {
for i, name := range wc.secretNames {
if name == secretName {
return wc.dnsNames[i], true
}
}
return "", false
}