blob: e26aa572ee6394429f94bb83c4f067eb91805218 [file]
// Copyright 2015 Google Inc. All Rights Reserved.
//
// 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 certs implements a CertSource which speaks to the public Cloud SQL API endpoint.
package certs
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"math"
mrand "math/rand"
"net/http"
"strings"
"sync"
"time"
"github.com/GoogleCloudPlatform/cloudsql-proxy/logging"
"github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/util"
"golang.org/x/oauth2"
"google.golang.org/api/googleapi"
sqladmin "google.golang.org/api/sqladmin/v1beta4"
)
const defaultUserAgent = "custom cloud_sql_proxy version >= 1.10"
// NewCertSource returns a CertSource which can be used to authenticate using
// the provided client, which must not be nil.
//
// This function is deprecated; use NewCertSourceOpts instead.
func NewCertSource(host string, c *http.Client, checkRegion bool) *RemoteCertSource {
return NewCertSourceOpts(c, RemoteOpts{
APIBasePath: host,
IgnoreRegion: !checkRegion,
UserAgent: defaultUserAgent,
})
}
// RemoteOpts are a collection of options for NewCertSourceOpts. All fields are
// optional.
type RemoteOpts struct {
// APIBasePath specifies the base path for the sqladmin API. If left blank,
// the default from the autogenerated sqladmin library is used (which is
// sufficient for nearly all users)
APIBasePath string
// IgnoreRegion specifies whether a missing or mismatched region in the
// instance name should be ignored. In a future version this value will be
// forced to 'false' by the RemoteCertSource.
IgnoreRegion bool
// A string for the RemoteCertSource to identify itself when contacting the
// sqladmin API.
UserAgent string
// IP address type options
IPAddrTypeOpts []string
// Enable IAM proxy db authentication
EnableIAMLogin bool
// Token source for token information used in cert creation
TokenSource oauth2.TokenSource
// DelayKeyGenerate, if true, causes the RSA key to be generated lazily
// on the first connection to a database. The default behavior is to generate
// the key when the CertSource is created.
DelayKeyGenerate bool
}
// NewCertSourceOpts returns a CertSource configured with the provided Opts.
// The provided http.Client must not be nil.
//
// Use this function instead of NewCertSource; it has a more forward-compatible
// signature.
func NewCertSourceOpts(c *http.Client, opts RemoteOpts) *RemoteCertSource {
serv, err := sqladmin.New(c)
if err != nil {
panic(err) // Only will happen if the provided client is nil.
}
if opts.APIBasePath != "" {
serv.BasePath = opts.APIBasePath
}
ua := opts.UserAgent
if ua == "" {
ua = defaultUserAgent
}
serv.UserAgent = ua
// Set default value to be "PUBLIC,PRIVATE" if not specified
if len(opts.IPAddrTypeOpts) == 0 {
opts.IPAddrTypeOpts = []string{"PUBLIC", "PRIVATE"}
}
// Add "PUBLIC" as an alias for "PRIMARY"
for index, ipAddressType := range opts.IPAddrTypeOpts {
if strings.ToUpper(ipAddressType) == "PUBLIC" {
opts.IPAddrTypeOpts[index] = "PRIMARY"
}
}
certSource := &RemoteCertSource{
serv: serv,
checkRegion: !opts.IgnoreRegion,
IPAddrTypes: opts.IPAddrTypeOpts,
EnableIAMLogin: opts.EnableIAMLogin,
TokenSource: opts.TokenSource,
}
if !opts.DelayKeyGenerate {
// Generate the RSA key now, but don't block on it.
go certSource.generateKey()
}
return certSource
}
// RemoteCertSource implements a CertSource, using Cloud SQL APIs to
// return Local certificates for identifying oneself as a specific user
// to the remote instance and Remote certificates for confirming the
// remote database's identity.
type RemoteCertSource struct {
// keyOnce is used to create `key` lazily.
keyOnce sync.Once
// key is the private key used for certificates returned by Local.
key *rsa.PrivateKey
// serv is used to make authenticated API calls to Cloud SQL.
serv *sqladmin.Service
// If set, providing an incorrect region in their connection string will be
// treated as an error. This is to provide the same functionality that will
// occur when API calls require the region.
checkRegion bool
// a list of ip address types that users select
IPAddrTypes []string
// flag to enable IAM proxy db authentication
EnableIAMLogin bool
// token source for the token information used in cert creation
TokenSource oauth2.TokenSource
}
// Constants for backoffAPIRetry. These cause the retry logic to scale the
// backoff delay from 200ms to around 3.5s.
const (
baseBackoff = float64(200 * time.Millisecond)
backoffMult = 1.618
backoffRetries = 5
)
func backoffAPIRetry(desc, instance string, do func(staleRead time.Time) error) error {
var (
err error
t time.Time
)
for i := 0; i < backoffRetries; i++ {
err = do(t)
gErr, ok := err.(*googleapi.Error)
switch {
case !ok:
// 'ok' will also be false if err is nil.
return err
case gErr.Code == 403 && len(gErr.Errors) > 0 && gErr.Errors[0].Reason == "insufficientPermissions":
// The case where the admin API has not yet been enabled.
return fmt.Errorf("ensure that the Cloud SQL API is enabled for your project (https://console.cloud.google.com/flows/enableapi?apiid=sqladmin). Error during %s %s: %v", desc, instance, err)
case gErr.Code == 404 || gErr.Code == 403:
return fmt.Errorf("ensure that the account has access to %q (and make sure there's no typo in that name). Error during %s %s: %v", instance, desc, instance, err)
case gErr.Code < 500:
// Only Server-level HTTP errors are immediately retryable.
return err
}
// sleep = baseBackoff * backoffMult^(retries + randomFactor)
exp := float64(i+1) + mrand.Float64()
sleep := time.Duration(baseBackoff * math.Pow(backoffMult, exp))
logging.Errorf("Error in %s %s: %v; retrying in %v", desc, instance, err, sleep)
time.Sleep(sleep)
// Create timestamp 30 seconds before now for stale read requests
t = time.Now().UTC().Add(-30 * time.Second)
}
return err
}
func refreshToken(ts oauth2.TokenSource, tok *oauth2.Token) (*oauth2.Token, error) {
expiredToken := &oauth2.Token{
AccessToken: tok.AccessToken,
TokenType: tok.TokenType,
RefreshToken: tok.RefreshToken,
Expiry: time.Time{}.Add(1), // Expired
}
return oauth2.ReuseTokenSource(expiredToken, ts).Token()
}
// Local returns a certificate that may be used to establish a TLS
// connection to the specified instance.
func (s *RemoteCertSource) Local(instance string) (tls.Certificate, error) {
pkix, err := x509.MarshalPKIXPublicKey(s.generateKey().Public())
if err != nil {
return tls.Certificate{}, err
}
p, r, n := util.SplitName(instance)
regionName := fmt.Sprintf("%s~%s", r, n)
pubKey := string(pem.EncodeToMemory(&pem.Block{Bytes: pkix, Type: "RSA PUBLIC KEY"}))
generateEphemeralCertRequest := &sqladmin.GenerateEphemeralCertRequest{
PublicKey: pubKey,
}
var tok *oauth2.Token
// If IAM login is enabled, add the OAuth2 token into the ephemeral
// certificate request.
if s.EnableIAMLogin {
var tokErr error
tok, tokErr = s.TokenSource.Token()
if tokErr != nil {
return tls.Certificate{}, tokErr
}
// Always refresh the token to ensure its expiration is far enough in
// the future.
tok, tokErr = refreshToken(s.TokenSource, tok)
if tokErr != nil {
return tls.Certificate{}, tokErr
}
generateEphemeralCertRequest.AccessToken = tok.AccessToken
}
req := s.serv.Connect.GenerateEphemeralCert(p, regionName, generateEphemeralCertRequest)
var data *sqladmin.GenerateEphemeralCertResponse
err = backoffAPIRetry("generateEphemeral for", instance, func(staleRead time.Time) error {
if !staleRead.IsZero() {
generateEphemeralCertRequest.ReadTime = staleRead.Format(time.RFC3339)
}
data, err = req.Do()
return err
})
if err != nil {
return tls.Certificate{}, err
}
c, err := parseCert(data.EphemeralCert.Cert)
if err != nil {
return tls.Certificate{}, fmt.Errorf("couldn't parse ephemeral certificate for instance %q: %v", instance, err)
}
if s.EnableIAMLogin {
// Adjust the certificate's expiration to be the earlier of tok.Expiry or c.NotAfter
if tok.Expiry.Before(c.NotAfter) {
c.NotAfter = tok.Expiry
}
}
return tls.Certificate{
Certificate: [][]byte{c.Raw},
PrivateKey: s.generateKey(),
Leaf: c,
}, nil
}
func parseCert(pemCert string) (*x509.Certificate, error) {
bl, _ := pem.Decode([]byte(pemCert))
if bl == nil {
return nil, errors.New("invalid PEM: " + pemCert)
}
return x509.ParseCertificate(bl.Bytes)
}
// Return the RSA private key, which is lazily initialized.
func (s *RemoteCertSource) generateKey() *rsa.PrivateKey {
s.keyOnce.Do(func() {
start := time.Now()
pkey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err) // very unexpected.
}
logging.Verbosef("Generated RSA key in %v", time.Since(start))
s.key = pkey
})
return s.key
}
// Find the first matching IP address by user input IP address types
func (s *RemoteCertSource) findIPAddr(data *sqladmin.ConnectSettings, instance string) (ipAddrInUse string, err error) {
for _, eachIPAddrTypeByUser := range s.IPAddrTypes {
for _, eachIPAddrTypeOfInstance := range data.IpAddresses {
if strings.ToUpper(eachIPAddrTypeOfInstance.Type) == strings.ToUpper(eachIPAddrTypeByUser) {
ipAddrInUse = eachIPAddrTypeOfInstance.IpAddress
return ipAddrInUse, nil
}
}
}
ipAddrTypesOfInstance := ""
for _, eachIPAddrTypeOfInstance := range data.IpAddresses {
ipAddrTypesOfInstance += fmt.Sprintf("(TYPE=%v, IP_ADDR=%v)", eachIPAddrTypeOfInstance.Type, eachIPAddrTypeOfInstance.IpAddress)
}
ipAddrTypeOfUser := fmt.Sprintf("%v", s.IPAddrTypes)
return "", fmt.Errorf("User input IP address type %v does not match the instance %v, the instance's IP addresses are %v ", ipAddrTypeOfUser, instance, ipAddrTypesOfInstance)
}
// Remote returns the specified instance's CA certificate, address, and name.
func (s *RemoteCertSource) Remote(instance string) (cert *x509.Certificate, addr, name, version string, err error) {
p, region, n := util.SplitName(instance)
regionName := fmt.Sprintf("%s~%s", region, n)
req := s.serv.Connect.Get(p, regionName)
var data *sqladmin.ConnectSettings
err = backoffAPIRetry("get instance", instance, func(staleRead time.Time) error {
if !staleRead.IsZero() {
req.ReadTime(staleRead.Format(time.RFC3339))
}
data, err = req.Do()
return err
})
if err != nil {
return nil, "", "", "", err
}
// TODO(chowski): remove this when us-central is removed.
if data.Region == "us-central" {
data.Region = "us-central1"
}
if data.Region != region {
if region == "" {
err = fmt.Errorf("instance %v doesn't provide region", instance)
} else {
err = fmt.Errorf(`for connection string "%s": got region %q, want %q`, instance, region, data.Region)
}
if s.checkRegion {
return nil, "", "", "", err
}
logging.Errorf("%v", err)
logging.Errorf("WARNING: specifying the correct region in an instance string will become required in a future version!")
}
if len(data.IpAddresses) == 0 {
return nil, "", "", "", fmt.Errorf("no IP address found for %v", instance)
}
if data.BackendType == "FIRST_GEN" {
logging.Errorf("WARNING: proxy client does not support first generation Cloud SQL instances.")
return nil, "", "", "", fmt.Errorf("%q is a first generation instance", instance)
}
// Find the first matching IP address by user input IP address types
ipAddrInUse := ""
ipAddrInUse, err = s.findIPAddr(data, instance)
if err != nil {
return nil, "", "", "", err
}
c, err := parseCert(data.ServerCaCert.Cert)
return c, ipAddrInUse, p + ":" + n, data.DatabaseVersion, err
}