blob: 77deda070ff660e1419570cc2ccf4222cd91047c [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 caclient
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"strings"
"time"
)
import (
"go.uber.org/atomic"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/structpb"
pb "istio.io/api/security/v1alpha1"
"istio.io/pkg/log"
)
import (
"github.com/apache/dubbo-go-pixiu/pkg/security"
"github.com/apache/dubbo-go-pixiu/security/pkg/nodeagent/caclient"
)
const (
bearerTokenPrefix = "Bearer "
)
var citadelClientLog = log.RegisterScope("citadelclient", "citadel client debugging", 0)
type CitadelClient struct {
// It means enable tls connection to Citadel if this is not nil.
tlsOpts *TLSOptions
client pb.IstioCertificateServiceClient
conn *grpc.ClientConn
provider *caclient.TokenProvider
opts *security.Options
usingMtls *atomic.Bool
}
type TLSOptions struct {
RootCert string
Key string
Cert string
}
// NewCitadelClient create a CA client for Citadel.
func NewCitadelClient(opts *security.Options, tlsOpts *TLSOptions) (*CitadelClient, error) {
c := &CitadelClient{
tlsOpts: tlsOpts,
opts: opts,
provider: caclient.NewCATokenProvider(opts),
usingMtls: atomic.NewBool(false),
}
conn, err := c.buildConnection()
if err != nil {
citadelClientLog.Errorf("Failed to connect to endpoint %s: %v", opts.CAEndpoint, err)
return nil, fmt.Errorf("failed to connect to endpoint %s", opts.CAEndpoint)
}
c.conn = conn
c.client = pb.NewIstioCertificateServiceClient(conn)
return c, nil
}
func (c *CitadelClient) Close() {
if c.conn != nil {
c.conn.Close()
}
}
// CSRSign calls Citadel to sign a CSR.
func (c *CitadelClient) CSRSign(csrPEM []byte, certValidTTLInSec int64) ([]string, error) {
crMetaStruct := &structpb.Struct{
Fields: map[string]*structpb.Value{
security.CertSigner: {
Kind: &structpb.Value_StringValue{StringValue: c.opts.CertSigner},
},
},
}
req := &pb.IstioCertificateRequest{
Csr: string(csrPEM),
ValidityDuration: certValidTTLInSec,
Metadata: crMetaStruct,
}
if err := c.reconnectIfNeeded(); err != nil {
return nil, err
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("ClusterID", c.opts.ClusterID))
resp, err := c.client.CreateCertificate(ctx, req)
if err != nil {
return nil, fmt.Errorf("create certificate: %v", err)
}
if len(resp.CertChain) <= 1 {
return nil, errors.New("invalid empty CertChain")
}
return resp.CertChain, nil
}
func (c *CitadelClient) getTLSDialOption() (grpc.DialOption, error) {
certPool, err := getRootCertificate(c.tlsOpts.RootCert)
if err != nil {
return nil, err
}
config := tls.Config{
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
var certificate tls.Certificate
key, cert := c.tlsOpts.Key, c.tlsOpts.Cert
if cert != "" {
var isExpired bool
isExpired, err = c.isCertExpired(cert)
if err != nil {
citadelClientLog.Warnf("cannot parse the cert chain, using token instead: %v", err)
return &certificate, nil
}
if isExpired {
citadelClientLog.Warnf("cert expired, using token instead")
return &certificate, nil
}
// Load the certificate from disk
certificate, err = tls.LoadX509KeyPair(cert, key)
if err != nil {
return nil, err
}
c.usingMtls.Store(true)
}
return &certificate, nil
},
RootCAs: certPool,
}
// For debugging on localhost (with port forward)
// TODO: remove once istiod is stable and we have a way to validate JWTs locally
if strings.Contains(c.opts.CAEndpoint, "localhost") {
config.ServerName = "istiod.dubbo-system.svc"
}
if c.opts.CAEndpointSAN != "" {
config.ServerName = c.opts.CAEndpointSAN
}
transportCreds := credentials.NewTLS(&config)
return grpc.WithTransportCredentials(transportCreds), nil
}
func getRootCertificate(rootCertFile string) (*x509.CertPool, error) {
if rootCertFile == "" {
// No explicit certificate - assume the citadel-compatible server uses a public cert
certPool, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
citadelClientLog.Info("Citadel client using system cert")
return certPool, nil
}
certPool := x509.NewCertPool()
rootCert, err := os.ReadFile(rootCertFile)
if err != nil {
return nil, err
}
ok := certPool.AppendCertsFromPEM(rootCert)
if !ok {
return nil, fmt.Errorf("failed to append certificates")
}
citadelClientLog.Info("Citadel client using custom root cert: ", rootCertFile)
return certPool, nil
}
func (c *CitadelClient) isCertExpired(filepath string) (bool, error) {
var err error
var certPEMBlock []byte
certPEMBlock, err = os.ReadFile(filepath)
if err != nil {
return true, fmt.Errorf("failed to read the cert, error is %v", err)
}
var certDERBlock *pem.Block
certDERBlock, _ = pem.Decode(certPEMBlock)
if certDERBlock == nil {
return true, fmt.Errorf("failed to decode certificate")
}
x509Cert, err := x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
return true, fmt.Errorf("failed to parse the cert, err is %v", err)
}
return x509Cert.NotAfter.Before(time.Now()), nil
}
func (c *CitadelClient) buildConnection() (*grpc.ClientConn, error) {
var opts grpc.DialOption
var err error
// CA tls disabled
if c.tlsOpts == nil {
opts = grpc.WithTransportCredentials(insecure.NewCredentials())
} else {
opts, err = c.getTLSDialOption()
if err != nil {
return nil, err
}
}
conn, err := grpc.Dial(c.opts.CAEndpoint,
opts,
grpc.WithPerRPCCredentials(c.provider),
security.CARetryInterceptor())
if err != nil {
citadelClientLog.Errorf("Failed to connect to endpoint %s: %v", c.opts.CAEndpoint, err)
return nil, fmt.Errorf("failed to connect to endpoint %s", c.opts.CAEndpoint)
}
return conn, nil
}
func (c *CitadelClient) reconnectIfNeeded() error {
if c.opts.ProvCert == "" || c.usingMtls.Load() {
// No need to reconnect, already using mTLS or never will use it
return nil
}
_, err := tls.LoadX509KeyPair(c.tlsOpts.Cert, c.tlsOpts.Key)
if err != nil {
// Cannot load the certificates yet, don't both reconnecting
return nil
}
if err := c.conn.Close(); err != nil {
return fmt.Errorf("failed to close connection")
}
conn, err := c.buildConnection()
if err != nil {
return err
}
c.conn = conn
c.client = pb.NewIstioCertificateServiceClient(conn)
citadelClientLog.Errorf("recreated connection")
return nil
}
// GetRootCertBundle: Citadel (Istiod) CA doesn't publish any endpoint to retrieve CA certs
func (c *CitadelClient) GetRootCertBundle() ([]string, error) {
return []string{}, nil
}